diff options
Diffstat (limited to 'src/client/apis')
-rw-r--r-- | src/client/apis/google_docs/GoogleApiClientUtils.ts | 265 | ||||
-rw-r--r-- | src/client/apis/google_docs/GooglePhotosClientUtils.ts | 328 |
2 files changed, 454 insertions, 139 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index 798886def..828d4451a 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -3,111 +3,130 @@ import { PostToServer } from "../../../Utils"; import { RouteStore } from "../../../server/RouteStore"; import { Opt } from "../../../new_fields/Doc"; import { isArray } from "util"; +import { EditorState } from "prosemirror-state"; +import { RichTextField } from "../../../new_fields/RichTextField"; export const Pulls = "googleDocsPullCount"; export const Pushes = "googleDocsPushCount"; export namespace GoogleApiClientUtils { - export enum Service { - Documents = "Documents", - Slides = "Slides" - } - export enum Actions { Create = "create", Retrieve = "retrieve", Update = "update" } - export enum WriteMode { - Insert, - Replace - } - - export type Identifier = string; - export type Reference = Identifier | CreateOptions; - export type TextContent = string | string[]; - export type IdHandler = (id: Identifier) => any; - export type CreationResult = Opt<Identifier>; - export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>; - export type ReadResult = { title?: string, body?: string }; + export namespace Docs { - export interface CreateOptions { - service: Service; - title?: string; // if excluded, will use a default title annotated with the current date - } + export type RetrievalResult = Opt<docs_v1.Schema$Document>; + export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>; - export interface RetrieveOptions { - service: Service; - identifier: Identifier; - } + export interface UpdateOptions { + documentId: DocumentId; + requests: docs_v1.Schema$Request[]; + } - export interface ReadOptions { - identifier: Identifier; - removeNewlines?: boolean; - } + export enum WriteMode { + Insert, + Replace + } - export interface WriteOptions { - mode: WriteMode; - content: TextContent; - reference: Reference; - index?: number; // if excluded, will compute the last index of the document and append the content there - } + export type DocumentId = string; + export type Reference = DocumentId | CreateOptions; + export interface Content { + text: string | string[]; + requests: docs_v1.Schema$Request[]; + } + export type IdHandler = (id: DocumentId) => any; + export type CreationResult = Opt<DocumentId>; + export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>; + export type ReadResult = { title: string, body: string }; + export interface ImportResult { + title: string; + text: string; + state: EditorState; + } - /** - * After following the authentication routine, which connects this API call to the current signed in account - * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which - * should appear in the user's Google Doc library instantaneously. - * - * @param options the title to assign to the new document, and the information necessary - * to store the new documentId returned from the creation process - * @returns the documentId of the newly generated document, or undefined if the creation process fails. - */ - export const create = async (options: CreateOptions): Promise<CreationResult> => { - const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Create}`; - const parameters = { - requestBody: { - title: options.title || `Dash Export (${new Date().toDateString()})` - } - }; - try { - const schema: any = await PostToServer(path, parameters); - let key = ["document", "presentation"].find(prefix => `${prefix}Id` in schema) + "Id"; - return schema[key]; - } catch { - return undefined; + export interface CreateOptions { + title?: string; // if excluded, will use a default title annotated with the current date } - }; - export namespace Docs { + export interface RetrieveOptions { + documentId: DocumentId; + } - export type RetrievalResult = Opt<docs_v1.Schema$Document | slides_v1.Schema$Presentation>; - export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>; + export interface ReadOptions { + documentId: DocumentId; + removeNewlines?: boolean; + } - export interface UpdateOptions { - documentId: Identifier; - requests: docs_v1.Schema$Request[]; + export interface WriteOptions { + mode: WriteMode; + content: Content; + reference: Reference; + index?: number; // if excluded, will compute the last index of the document and append the content there } + /** + * After following the authentication routine, which connects this API call to the current signed in account + * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which + * should appear in the user's Google Doc library instantaneously. + * + * @param options the title to assign to the new document, and the information necessary + * to store the new documentId returned from the creation process + * @returns the documentId of the newly generated document, or undefined if the creation process fails. + */ + export const create = async (options: CreateOptions): Promise<CreationResult> => { + const path = `${RouteStore.googleDocs}/Documents/${Actions.Create}`; + const parameters = { + requestBody: { + title: options.title || `Dash Export (${new Date().toDateString()})` + } + }; + try { + const schema: docs_v1.Schema$Document = await PostToServer(path, parameters); + return schema.documentId; + } catch { + return undefined; + } + }; + export namespace Utils { - export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => { - const fragments: string[] = []; + export type ExtractResult = { text: string, paragraphs: DeconstructedParagraph[] }; + export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): ExtractResult => { + let paragraphs = extractParagraphs(document); + let text = paragraphs.map(paragraph => paragraph.runs.map(run => run.content).join("")).join(""); + text = text.substring(0, text.length - 1); + removeNewlines && text.ReplaceAll("\n", ""); + return { text, paragraphs }; + }; + + export type DeconstructedParagraph = { runs: docs_v1.Schema$TextRun[], bullet: Opt<number> }; + const extractParagraphs = (document: docs_v1.Schema$Document, filterEmpty = true): DeconstructedParagraph[] => { + const fragments: DeconstructedParagraph[] = []; if (document.body && document.body.content) { for (const element of document.body.content) { - if (element.paragraph && element.paragraph.elements) { - for (const inner of element.paragraph.elements) { - if (inner && inner.textRun) { - const fragment = inner.textRun.content; - fragment && fragments.push(fragment); + let runs: docs_v1.Schema$TextRun[] = []; + let bullet: Opt<number>; + if (element.paragraph) { + if (element.paragraph.elements) { + for (const inner of element.paragraph.elements) { + if (inner && inner.textRun) { + let run = inner.textRun; + (run.content || !filterEmpty) && runs.push(inner.textRun); + } } } + if (element.paragraph.bullet) { + bullet = element.paragraph.bullet.nestingLevel || 0; + } } + (runs.length || !filterEmpty) && fragments.push({ runs, bullet }); } } - const text = fragments.join(""); - return removeNewlines ? text.ReplaceAll("\n", "") : text; + return fragments; }; export const endOf = (schema: docs_v1.Schema$Document): number | undefined => { @@ -130,27 +149,19 @@ export namespace GoogleApiClientUtils { } - const KeyMapping = new Map<Service, string>([ - [Service.Documents, "documentId"], - [Service.Slides, "presentationId"] - ]); - export const retrieve = async (options: RetrieveOptions): Promise<RetrievalResult> => { - const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Retrieve}`; + const path = `${RouteStore.googleDocs}/Documents/${Actions.Retrieve}`; try { - let parameters: any = {}, key: string | undefined; - if ((key = KeyMapping.get(options.service))) { - parameters[key] = options.identifier; - const schema: RetrievalResult = await PostToServer(path, parameters); - return schema; - } + const parameters = { documentId: options.documentId }; + const schema: RetrievalResult = await PostToServer(path, parameters); + return schema; } catch { return undefined; } }; export const update = async (options: UpdateOptions): Promise<UpdateResult> => { - const path = `${RouteStore.googleDocs}/${Service.Documents}/${Actions.Update}`; + const path = `${RouteStore.googleDocs}/Documents/${Actions.Update}`; const parameters = { documentId: options.documentId, requestBody: { @@ -165,41 +176,49 @@ export namespace GoogleApiClientUtils { } }; - export const read = async (options: ReadOptions): Promise<ReadResult> => { - return retrieve({ ...options, service: Service.Documents }).then(document => { - let result: ReadResult = {}; + export const read = async (options: ReadOptions): Promise<Opt<ReadResult>> => { + return retrieve({ documentId: options.documentId }).then(document => { if (document) { - let title = document.title; - let body = Utils.extractText(document, options.removeNewlines); - result = { title, body }; + let title = document.title!; + let body = Utils.extractText(document, options.removeNewlines).text; + return { title, body }; } - return result; }); }; - export const readLines = async (options: ReadOptions): Promise<ReadLinesResult> => { - return retrieve({ ...options, service: Service.Documents }).then(document => { - let result: ReadLinesResult = {}; + export const readLines = async (options: ReadOptions): Promise<Opt<ReadLinesResult>> => { + return retrieve({ documentId: options.documentId }).then(document => { if (document) { let title = document.title; - let bodyLines = Utils.extractText(document).split("\n"); + let bodyLines = Utils.extractText(document).text.split("\n"); options.removeNewlines && (bodyLines = bodyLines.filter(line => line.length)); - result = { title, bodyLines }; + return { title, bodyLines }; } - return result; }); }; + export const setStyle = async (options: UpdateOptions) => { + let replies: any = await update({ + documentId: options.documentId, + requests: options.requests + }); + if ("errors" in replies) { + console.log("Write operation failed:"); + console.log(replies.errors.map((error: any) => error.message)); + } + return replies; + }; + export const write = async (options: WriteOptions): Promise<UpdateResult> => { const requests: docs_v1.Schema$Request[] = []; - const identifier = await Utils.initialize(options.reference); - if (!identifier) { + const documentId = await Utils.initialize(options.reference); + if (!documentId) { return undefined; } let index = options.index; const mode = options.mode; if (!(index && mode === WriteMode.Insert)) { - let schema = await retrieve({ identifier, service: Service.Documents }); + let schema = await retrieve({ documentId }); if (!schema || !(index = Utils.endOf(schema))) { return undefined; } @@ -215,7 +234,7 @@ export namespace GoogleApiClientUtils { }); index = 1; } - const text = options.content; + const text = options.content.text; text.length && requests.push({ insertText: { text: isArray(text) ? text.join("\n") : text, @@ -225,47 +244,15 @@ export namespace GoogleApiClientUtils { if (!requests.length) { return undefined; } - let replies: any = await update({ documentId: identifier, requests }); - let errors = "errors"; - if (errors in replies) { + requests.push(...options.content.requests); + let replies: any = await update({ documentId: documentId, requests }); + if ("errors" in replies) { console.log("Write operation failed:"); - console.log(replies[errors].map((error: any) => error.message)); + console.log(replies.errors.map((error: any) => error.message)); } return replies; }; } - export namespace Slides { - - export namespace Utils { - - export const extractTextBoxes = (slides: slides_v1.Schema$Page[]) => { - slides.map(slide => { - let elements = slide.pageElements; - if (elements) { - let textboxes: slides_v1.Schema$TextContent[] = []; - for (let element of elements) { - if (element && element.shape && element.shape.shapeType === "TEXT_BOX" && element.shape.text) { - textboxes.push(element.shape.text); - } - } - textboxes.map(text => { - if (text.textElements) { - text.textElements.map(element => { - - }); - } - if (text.lists) { - - } - }); - } - }); - }; - - } - - } - }
\ No newline at end of file diff --git a/src/client/apis/google_docs/GooglePhotosClientUtils.ts b/src/client/apis/google_docs/GooglePhotosClientUtils.ts new file mode 100644 index 000000000..700c0401a --- /dev/null +++ b/src/client/apis/google_docs/GooglePhotosClientUtils.ts @@ -0,0 +1,328 @@ +import { PostToServer, Utils } from "../../../Utils"; +import { RouteStore } from "../../../server/RouteStore"; +import { ImageField } from "../../../new_fields/URLField"; +import { Cast, StrCast } from "../../../new_fields/Types"; +import { Doc, Opt, DocListCastAsync } from "../../../new_fields/Doc"; +import { Id } from "../../../new_fields/FieldSymbols"; +import Photos = require('googlephotos'); +import { RichTextField } from "../../../new_fields/RichTextField"; +import { RichTextUtils } from "../../../new_fields/RichTextUtils"; +import { EditorState } from "prosemirror-state"; +import { FormattedTextBox } from "../../views/nodes/FormattedTextBox"; +import { Docs, DocumentOptions } from "../../documents/Documents"; +import { NewMediaItemResult, MediaItem } from "../../../server/apis/google/SharedTypes"; +import { AssertionError } from "assert"; +import { List } from "../../../new_fields/List"; +import { listSpec } from "../../../new_fields/Schema"; + +export namespace GooglePhotos { + + const endpoint = async () => { + const getToken = Utils.prepend(RouteStore.googlePhotosAccessToken); + const token = await (await fetch(getToken)).text(); + return new Photos(token); + }; + + export enum MediaType { + ALL_MEDIA = 'ALL_MEDIA', + PHOTO = 'PHOTO', + VIDEO = 'VIDEO' + } + + export type AlbumReference = { id: string } | { title: string }; + + export interface MediaInput { + url: string; + description: string; + } + + export const ContentCategories = { + NONE: 'NONE', + LANDSCAPES: 'LANDSCAPES', + RECEIPTS: 'RECEIPTS', + CITYSCAPES: 'CITYSCAPES', + LANDMARKS: 'LANDMARKS', + SELFIES: 'SELFIES', + PEOPLE: 'PEOPLE', + PETS: 'PETS', + WEDDINGS: 'WEDDINGS', + BIRTHDAYS: 'BIRTHDAYS', + DOCUMENTS: 'DOCUMENTS', + TRAVEL: 'TRAVEL', + ANIMALS: 'ANIMALS', + FOOD: 'FOOD', + SPORT: 'SPORT', + NIGHT: 'NIGHT', + PERFORMANCES: 'PERFORMANCES', + WHITEBOARDS: 'WHITEBOARDS', + SCREENSHOTS: 'SCREENSHOTS', + UTILITY: 'UTILITY', + ARTS: 'ARTS', + CRAFTS: 'CRAFTS', + FASHION: 'FASHION', + HOUSES: 'HOUSES', + GARDENS: 'GARDENS', + FLOWERS: 'FLOWERS', + HOLIDAYS: 'HOLIDAYS' + }; + + export namespace Export { + + export interface AlbumCreationResult { + albumId: string; + mediaItems: MediaItem[]; + } + + export interface AlbumCreationOptions { + collection: Doc; + title?: string; + descriptionKey?: string; + tag?: boolean; + } + + export const CollectionToAlbum = async (options: AlbumCreationOptions): Promise<Opt<AlbumCreationResult>> => { + const { collection, title, descriptionKey, tag } = options; + const dataDocument = Doc.GetProto(collection); + const images = ((await DocListCastAsync(dataDocument.data)) || []).filter(doc => Cast(doc.data, ImageField)); + if (!images || !images.length) { + return undefined; + } + const resolved = title ? title : (StrCast(collection.title) || `Dash Collection (${collection[Id]}`); + const { id } = await Create.Album(resolved); + const newMediaItemResults = await Transactions.UploadImages(images, { id }, descriptionKey); + if (newMediaItemResults) { + const mediaItems = newMediaItemResults.map(item => item.mediaItem); + if (mediaItems.length !== images.length) { + throw new AssertionError({ actual: mediaItems.length, expected: images.length }); + } + const idMapping = new Doc; + for (let i = 0; i < images.length; i++) { + const image = images[i]; + const mediaItem = mediaItems[i]; + image.googlePhotosId = mediaItem.id; + image.googlePhotosUrl = mediaItem.baseUrl || mediaItem.productUrl; + idMapping[mediaItem.id] = image; + } + collection.googlePhotosIdMapping = idMapping; + if (tag) { + await Query.TagChildImages(collection); + } + collection.albumId = id; + return { albumId: id, mediaItems }; + } + }; + + } + + export namespace Import { + + export type CollectionConstructor = (data: Array<Doc>, options: DocumentOptions, ...args: any) => Doc; + + export const CollectionFromSearch = async (constructor: CollectionConstructor, requested: Opt<Partial<Query.SearchOptions>>): Promise<Doc> => { + let response = await Query.Search(requested); + let uploads = await Transactions.WriteMediaItemsToServer(response); + const children = uploads.map((upload: Transactions.UploadInformation) => { + let document = Docs.Create.ImageDocument(Utils.fileUrl(upload.fileNames.clean)); + document.fillColumn = true; + document.contentSize = upload.contentSize; + return document; + }); + const options = { width: 500, height: 500 }; + return constructor(children, options); + }; + + } + + export namespace Query { + + const delimiter = ", "; + export const TagChildImages = async (collection: Doc) => { + const idMapping = await Cast(collection.googlePhotosIdMapping, Doc); + if (!idMapping) { + throw new Error("Appending image metadata requires that the targeted collection have already been mapped to an album!"); + } + const tagMapping = new Map<string, string>(); + const images = await DocListCastAsync(collection.data); + images && images.forEach(image => tagMapping.set(image[Id], ContentCategories.NONE)); + const values = Object.values(ContentCategories); + for (let value of values) { + if (value !== ContentCategories.NONE) { + const results = await Search({ included: [value] }); + if (results.mediaItems) { + const ids = results.mediaItems.map(item => item.id); + for (let id of ids) { + const image = await Cast(idMapping[id], Doc); + if (image) { + const key = image[Id]; + const tags = tagMapping.get(key)!; + if (!tags.includes(value)) { + tagMapping.set(key, tags + delimiter + value); + } + } + } + } + } + } + images && images.forEach(image => { + const concatenated = tagMapping.get(image[Id])!; + const tags = concatenated.split(delimiter); + if (tags.length > 1) { + const cleaned = concatenated.replace(ContentCategories.NONE + delimiter, ""); + image.googlePhotosTags = cleaned.split(delimiter).sort((a, b) => (a < b) ? -1 : (a > b ? 1 : 0)).join(delimiter); + } + }); + + }; + + interface DateRange { + after: Date; + before: Date; + } + + const DefaultSearchOptions: SearchOptions = { + pageSize: 50, + included: [], + excluded: [], + date: undefined, + includeArchivedMedia: true, + excludeNonAppCreatedData: false, + type: MediaType.ALL_MEDIA, + }; + + export interface SearchOptions { + pageSize: number; + included: string[]; + excluded: string[]; + date: Opt<Date | DateRange>; + includeArchivedMedia: boolean; + excludeNonAppCreatedData: boolean; + type: MediaType; + } + + export interface SearchResponse { + mediaItems: any[]; + nextPageToken: string; + } + + export const Search = async (requested: Opt<Partial<SearchOptions>>): Promise<SearchResponse> => { + const options = requested || DefaultSearchOptions; + const photos = await endpoint(); + const filters = new photos.Filters(options.includeArchivedMedia === undefined ? true : options.includeArchivedMedia); + + const included = options.included || []; + const excluded = options.excluded || []; + const contentFilter = new photos.ContentFilter(); + included.length && included.forEach(category => contentFilter.addIncludedContentCategories(category)); + excluded.length && excluded.forEach(category => contentFilter.addExcludedContentCategories(category)); + filters.setContentFilter(contentFilter); + + const date = options.date; + if (date) { + const dateFilter = new photos.DateFilter(); + if (date instanceof Date) { + dateFilter.addDate(date); + } else { + dateFilter.addRange(date.after, date.before); + } + filters.setDateFilter(dateFilter); + } + + filters.setMediaTypeFilter(new photos.MediaTypeFilter(options.type || MediaType.ALL_MEDIA)); + + return new Promise<SearchResponse>(resolve => { + photos.mediaItems.search(filters, options.pageSize || 100).then(resolve); + }); + }; + + export const GetImage = async (mediaItemId: string): Promise<Transactions.MediaItem> => { + return (await endpoint()).mediaItems.get(mediaItemId); + }; + + } + + namespace Create { + + export const Album = async (title: string) => { + return (await endpoint()).albums.create(title); + }; + + } + + export namespace Transactions { + + export interface UploadInformation { + mediaPaths: string[]; + fileNames: { [key: string]: string }; + contentSize?: number; + contentType?: string; + } + + export interface MediaItem { + id: string; + filename: string; + baseUrl: string; + } + + export const AddTextEnrichment = async (collection: Doc, content?: string) => { + const photos = await endpoint(); + const albumId = StrCast(collection.albumId); + if (albumId && albumId.length) { + const enrichment = new photos.TextEnrichment(content || Utils.prepend("/doc/" + collection[Id])); + const position = new photos.AlbumPosition(photos.AlbumPosition.POSITIONS.FIRST_IN_ALBUM); + const enrichmentItem = await photos.albums.addEnrichment(albumId, enrichment, position); + if (enrichmentItem) { + return enrichmentItem.id; + } + } + }; + + export const WriteMediaItemsToServer = async (body: { mediaItems: any[] }): Promise<UploadInformation[]> => { + const uploads = await PostToServer(RouteStore.googlePhotosMediaDownload, body); + return uploads; + }; + + export const UploadThenFetch = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption") => { + const newMediaItems = await UploadImages(sources, album, descriptionKey); + if (!newMediaItems) { + return undefined; + } + const baseUrls: string[] = await Promise.all(newMediaItems.map(item => { + return new Promise<string>(resolve => Query.GetImage(item.mediaItem.id).then(item => resolve(item.baseUrl))); + })); + return baseUrls; + }; + + export const UploadImages = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption"): Promise<Opt<NewMediaItemResult[]>> => { + if (album && "title" in album) { + album = await Create.Album(album.title); + } + const media: MediaInput[] = []; + sources.forEach(source => { + const data = Cast(Doc.GetProto(source).data, ImageField); + if (!data) { + return; + } + const url = data.url.href; + const description = parseDescription(source, descriptionKey); + media.push({ url, description }); + }); + if (media.length) { + const uploads: NewMediaItemResult[] = await PostToServer(RouteStore.googlePhotosMediaUpload, { media, album }); + return uploads; + } + }; + + const parseDescription = (document: Doc, descriptionKey: string) => { + let description: string = Utils.prepend("/doc/" + document[Id]); + const target = document[descriptionKey]; + if (typeof target === "string") { + description = target; + } else if (target instanceof RichTextField) { + description = RichTextUtils.ToPlainText(EditorState.fromJSON(FormattedTextBox.Instance.config, JSON.parse(target.Data))); + } + return description; + }; + + } + +}
\ No newline at end of file |