diff options
-rw-r--r-- | src/client/apis/google_docs/GoogleApiClientUtils.ts | 19 | ||||
-rw-r--r-- | src/client/views/nodes/FormattedTextBox.tsx | 6 | ||||
-rw-r--r-- | src/new_fields/RichTextUtils.ts | 115 | ||||
-rw-r--r-- | src/server/apis/google/GooglePhotosUploadUtils.ts | 56 | ||||
-rw-r--r-- | src/server/apis/google/existing_uploads.json | 1 | ||||
-rw-r--r-- | src/server/credentials/google_docs_token.json | 2 | ||||
-rw-r--r-- | src/server/index.ts | 30 |
7 files changed, 162 insertions, 67 deletions
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index 828d4451a..cbc5da15b 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -97,25 +97,30 @@ export namespace GoogleApiClientUtils { 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(""); + let text = paragraphs.map(paragraph => paragraph.contents.filter(content => !("inlineObjectId" in content)).map(run => run as docs_v1.Schema$TextRun).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> }; + export type ContentArray = (docs_v1.Schema$TextRun | docs_v1.Schema$InlineObjectElement)[]; + export type DeconstructedParagraph = { contents: ContentArray, 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) { - let runs: docs_v1.Schema$TextRun[] = []; + let runs: ContentArray = []; 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 (inner) { + if (inner.textRun) { + let run = inner.textRun; + (run.content || !filterEmpty) && runs.push(inner.textRun); + } else if (inner.inlineObjectElement) { + runs.push(inner.inlineObjectElement); + } } } } @@ -123,7 +128,7 @@ export namespace GoogleApiClientUtils { bullet = element.paragraph.bullet.nestingLevel || 0; } } - (runs.length || !filterEmpty) && fragments.push({ runs, bullet }); + (runs.length || !filterEmpty) && fragments.push({ contents: runs, bullet }); } } return fragments; diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index fc5b27220..8f0f142c4 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -193,8 +193,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe else DocUtils.MakeLink(this.dataDoc, this.dataDoc[key] as Doc, undefined, "Ref:" + value, undefined, undefined, id); }); }); - const link = this._editorView!.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value }); - const mval = this._editorView!.state.schema.marks.metadataVal.create(); + const link = this._editorView.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value }); + const mval = this._editorView.state.schema.marks.metadataVal.create(); let offset = (tx.selection.to === range!.end - 1 ? -1 : 0); tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval); this.dataDoc[key] = value; @@ -506,7 +506,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let documentId = StrCast(dataDoc[GoogleRef]); let exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>; if (documentId) { - exportState = await RichTextUtils.GoogleDocs.Import(documentId); + exportState = await RichTextUtils.GoogleDocs.Import(documentId, dataDoc); } UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); } diff --git a/src/new_fields/RichTextUtils.ts b/src/new_fields/RichTextUtils.ts index 555c41b67..ab5e677c8 100644 --- a/src/new_fields/RichTextUtils.ts +++ b/src/new_fields/RichTextUtils.ts @@ -14,9 +14,10 @@ import { schema } from "../client/util/RichTextSchema"; import { GooglePhotos } from "../client/apis/google_docs/GooglePhotosClientUtils"; import { SchemaHeaderField } from "./SchemaHeaderField"; import { DocServer } from "../client/DocServer"; -import { Cast } from "./Types"; +import { Cast, StrCast } from "./Types"; import { Id } from "./FieldSymbols"; import { DocumentView } from "../client/views/nodes/DocumentView"; +import { AssertionError } from "assert"; export namespace RichTextUtils { @@ -109,45 +110,78 @@ export namespace RichTextUtils { return { text, requests }; }; + interface ImageTemplate { + width: number; + title: string; + url: string; + } + + const parseInlineObjects = async (document: docs_v1.Schema$Document): Promise<Map<string, ImageTemplate>> => { + const inlineObjectMap = new Map<string, ImageTemplate>(); + const inlineObjects = document.inlineObjects; + + if (inlineObjects) { + const objects = Object.keys(inlineObjects).map(objectId => inlineObjects[objectId]); + const mediaItems: MediaItem[] = objects.map(object => { + const embeddedObject = object.inlineObjectProperties!.embeddedObject!; + const baseUrl = embeddedObject.imageProperties!.contentUri!; + const filename = `upload_${Utils.GenerateGuid()}.png`; + return { baseUrl, filename }; + }); + + const uploads = await PostToServer(RouteStore.googlePhotosMediaDownload, { mediaItems }); + + if (uploads.length !== mediaItems.length) { + throw new AssertionError({ expected: mediaItems.length, actual: uploads.length, message: "Error with internally uploading inlineObjects!" }); + } + + for (let i = 0; i < objects.length; i++) { + const object = objects[i]; + const { fileNames } = uploads[i]; + const embeddedObject = object.inlineObjectProperties!.embeddedObject!; + const size = embeddedObject.size!; + const width = size.width!.magnitude!; + const url = Utils.fileUrl(fileNames.clean); + + inlineObjectMap.set(object.objectId!, { + title: embeddedObject.title || `Imported Image from ${document.title}`, + width, + url + }); + } + } + return inlineObjectMap; + }; + type BulletPosition = { value: number, sinks: number }; interface MediaItem { baseUrl: string; filename: string; - width: number; } - export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => { + + export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId, textNote: Doc): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => { const document = await GoogleApiClientUtils.Docs.retrieve({ documentId }); if (!document) { return undefined; } - + const inlineObjectMap = await parseInlineObjects(document); const title = document.title!; const { text, paragraphs } = GoogleApiClientUtils.Docs.Utils.extractText(document); let state = FormattedTextBox.blankState(); let structured = parseLists(paragraphs); - const inline = document.inlineObjects; - let inlineUrls: MediaItem[] = []; - if (inline) { - inlineUrls = Object.keys(inline).map(key => { - const embedded = inline[key].inlineObjectProperties!.embeddedObject!; - const baseUrl = embedded.imageProperties!.contentUri!; - const filename = `upload_${Utils.GenerateGuid()}.png`; - const width = embedded.size!.width!.magnitude!; - return { baseUrl, filename, width }; - }); - } let position = 3; let lists: ListGroup[] = []; const indentMap = new Map<ListGroup, BulletPosition[]>(); let globalOffset = 0; - const nodes = structured.map(element => { + const nodes: Node<any>[] = []; + for (let element of structured) { if (Array.isArray(element)) { lists.push(element); let positions: BulletPosition[] = []; let items = element.map(paragraph => { - let item = listItem(state.schema, paragraph.runs); + let item = listItem(state.schema, paragraph.contents); let sinks = paragraph.bullet!; positions.push({ value: position + globalOffset, @@ -158,13 +192,26 @@ export namespace RichTextUtils { return item; }); indentMap.set(element, positions); - return list(state.schema, items); + nodes.push(list(state.schema, items)); } else { - let paragraph = paragraphNode(state.schema, element.runs); - position += paragraph.nodeSize; - return paragraph; + if (element.contents.some(child => "inlineObjectId" in child)) { + let node: Node<any>; + for (const child of element.contents) { + if ("inlineObjectId" in child) { + node = imageNode(state.schema, inlineObjectMap.get(child.inlineObjectId!)!, textNote); + } else { + node = paragraphNode(state.schema, [child]); + } + nodes.push(node); + position += node.nodeSize; + } + } else { + let paragraph = paragraphNode(state.schema, element.contents); + nodes.push(paragraph); + position += paragraph.nodeSize; + } } - }); + } state = state.apply(state.tr.replaceWith(0, 2, nodes)); let sink = sinkListItem(state.schema.nodes.list_item); @@ -179,14 +226,6 @@ export namespace RichTextUtils { } } - const uploads = await PostToServer(RouteStore.googlePhotosMediaDownload, { mediaItems: inlineUrls }); - for (let i = 0; i < uploads.length; i++) { - const src = Utils.fileUrl(uploads[i].fileNames.clean); - const width = inlineUrls[i].width; - const imageNode = schema.nodes.image.create({ src, width }); - state = state.apply(state.tr.insert(0, imageNode)); - } - return { title, text, state }; }; @@ -226,6 +265,22 @@ export namespace RichTextUtils { return schema.node("paragraph", null, fragment); }; + const imageNode = (schema: any, image: ImageTemplate, textNote: Doc) => { + const { url: src, width } = image; + let docid: string; + const guid = Utils.GenerateDeterministicGuid(src); + const backingDocId = StrCast(textNote[guid]); + if (!backingDocId) { + const backingDoc = Docs.Create.ImageDocument(src, { width: 300, height: 300 }); + DocumentView.makeCustomViewClicked(backingDoc); + docid = backingDoc[Id]; + textNote[guid] = docid; + } else { + docid = backingDocId; + } + return schema.node("image", { src, width, docid }); + }; + const textNode = (schema: any, run: docs_v1.Schema$TextRun) => { let text = run.content!.removeTrailingNewlines(); return text.length ? schema.text(text, styleToMarks(schema, run.textStyle)) : undefined; diff --git a/src/server/apis/google/GooglePhotosUploadUtils.ts b/src/server/apis/google/GooglePhotosUploadUtils.ts index f582cebd2..3ab9ba90f 100644 --- a/src/server/apis/google/GooglePhotosUploadUtils.ts +++ b/src/server/apis/google/GooglePhotosUploadUtils.ts @@ -118,28 +118,44 @@ export namespace DownloadUtils { const generate = (prefix: string, url: string) => `${prefix}upload_${Utils.GenerateGuid()}${path.extname(url).toLowerCase()}`; const sanitize = (filename: string) => filename.replace(/\s+/g, "_"); - export const UploadImage = async (url: string, filename?: string, prefix = ""): Promise<Opt<UploadInformation>> => { - const resolved = filename ? sanitize(filename) : generate(prefix, url); - let extension = path.extname(url) || path.extname(resolved); + export interface InspectionResults { + isLocal: boolean; + stream: any; + normalizedUrl: string; + contentSize: number; + contentType: string; + } + + export const InspectImage = async (url: string) => { + const { isLocal, stream, normalized: normalizedUrl } = classify(url); + const metadata = (await new Promise<any>((resolve, reject) => { + request.head(url, async (error, res) => { + if (error) { + return reject(error); + } + resolve(res); + }); + })).headers; + return { + contentSize: parseInt(metadata[size]), + contentType: metadata[type], + isLocal, + stream, + normalizedUrl + }; + }; + + export const UploadImage = async (metadata: InspectionResults, filename?: string, prefix = ""): Promise<Opt<UploadInformation>> => { + const { isLocal, stream, normalizedUrl, contentSize, contentType } = metadata; + const resolved = filename ? sanitize(filename) : generate(prefix, normalizedUrl); + let extension = path.extname(normalizedUrl) || path.extname(resolved); extension && (extension = extension.toLowerCase()); let information: UploadInformation = { mediaPaths: [], - fileNames: { clean: resolved } + fileNames: { clean: resolved }, + contentSize, + contentType, }; - const { isLocal, stream, normalized } = classify(url); - url = normalized; - if (!isLocal) { - const metadata = (await new Promise<any>((resolve, reject) => { - request.head(url, async (error, res) => { - if (error) { - return reject(error); - } - resolve(res); - }); - })).headers; - information.contentSize = parseInt(metadata[size]); - information.contentType = metadata[type]; - } return new Promise<UploadInformation>(async (resolve, reject) => { const resizers = [ { resizer: sharp().rotate(), suffix: "_o" }, @@ -164,7 +180,7 @@ export namespace DownloadUtils { const filename = resolved.substring(0, resolved.length - extension.length) + suffix + extension; information.mediaPaths.push(mediaPath = uploadDirectory + filename); information.fileNames[suffix] = filename; - stream(url).pipe(resizer.resizer).pipe(fs.createWriteStream(mediaPath)) + stream(normalizedUrl).pipe(resizer.resizer).pipe(fs.createWriteStream(mediaPath)) .on('close', resolve) .on('error', reject); }); @@ -172,7 +188,7 @@ export namespace DownloadUtils { } if (!isLocal || nonVisual) { await new Promise<void>(resolve => { - stream(url).pipe(fs.createWriteStream(uploadDirectory + resolved)).on('close', resolve); + stream(normalizedUrl).pipe(fs.createWriteStream(uploadDirectory + resolved)).on('close', resolve); }); } resolve(information); diff --git a/src/server/apis/google/existing_uploads.json b/src/server/apis/google/existing_uploads.json new file mode 100644 index 000000000..05c20c33b --- /dev/null +++ b/src/server/apis/google/existing_uploads.json @@ -0,0 +1 @@ +{"23625":{"mediaPaths":["C:\\Users\\avd\\Desktop\\Sam\\Dash-Web\\src\\server\\public\\files\\upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_o.png","C:\\Users\\avd\\Desktop\\Sam\\Dash-Web\\src\\server\\public\\files\\upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_s.png","C:\\Users\\avd\\Desktop\\Sam\\Dash-Web\\src\\server\\public\\files\\upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_m.png","C:\\Users\\avd\\Desktop\\Sam\\Dash-Web\\src\\server\\public\\files\\upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_l.png"],"fileNames":{"clean":"upload_7e2d5fef-860a-49a8-b9ec-b91f28073180.png","_o":"upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_o.png","_s":"upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_s.png","_m":"upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_m.png","_l":"upload_7e2d5fef-860a-49a8-b9ec-b91f28073180_l.png"},"contentSize":23625,"contentType":"image/jpeg"}}
\ No newline at end of file diff --git a/src/server/credentials/google_docs_token.json b/src/server/credentials/google_docs_token.json index 4f2fb0f9d..c10b0797f 100644 --- a/src/server/credentials/google_docs_token.json +++ b/src/server/credentials/google_docs_token.json @@ -1 +1 @@ -{"access_token":"ya29.GlyEB-6kaRm7dCD9x3j1b5AyujXvfpS5NWuJQwy6UKLO06KYXcF2e5XaCxvR7QJgH3Pn2iu3btjYrrJxNNaLffgEszcJHNsN_5IIWJBA4sdG6KLW63MmFwfV4U1hyQ","refresh_token":"1/HTv_xFHszu2Nf3iiFrUTaeKzC_Vp2-6bpIB06xW_WHI","scope":"https://www.googleapis.com/auth/presentations.readonly https://www.googleapis.com/auth/documents.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/photoslibrary https://www.googleapis.com/auth/photoslibrary.appendonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/presentations https://www.googleapis.com/auth/photoslibrary.sharing","token_type":"Bearer","expiry_date":1568573667294}
\ No newline at end of file +{"access_token":"ya29.ImCFB_ghOybVB6A4HvIIwIlyGyZw6wOymdwJyWJJECIpCmFTHNEzOAfP98KFzm5OUV2zZNS5Wx1iUT1xYWW35PY7NoZc7PWwjzmOaGkMzDm7_fxpsgjT0StdvEwTJprFIv0","refresh_token":"1/HTv_xFHszu2Nf3iiFrUTaeKzC_Vp2-6bpIB06xW_WHI","scope":"https://www.googleapis.com/auth/presentations.readonly https://www.googleapis.com/auth/documents.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/photoslibrary https://www.googleapis.com/auth/photoslibrary.appendonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/presentations https://www.googleapis.com/auth/photoslibrary.sharing","token_type":"Bearer","expiry_date":1568590984976}
\ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index 2e60d9be7..07ce4b6f0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -46,6 +46,7 @@ const MongoStore = require('connect-mongo')(session); const mongoose = require('mongoose'); const probe = require("probe-image-size"); import * as qs from 'query-string'; +import { Opt } from '../new_fields/Doc'; const extensions = require("../client/util/UtilExtensions"); const download = (url: string, dest: fs.PathLike) => request.get(url).pipe(fs.createWriteStream(dest)); @@ -580,7 +581,8 @@ app.post( for (const key in files) { const { type, path: location, name } = files[key]; const filename = path.basename(location); - await UploadUtils.UploadImage(uploadDirectory + filename, filename).catch(() => console.log(`Unable to process ${filename}`)); + const metadata = await UploadUtils.InspectImage(uploadDirectory + filename); + await UploadUtils.UploadImage(metadata, filename).catch(() => console.log(`Unable to process ${filename}`)); results.push({ name, type, path: `/files/${filename}` }); } _success(res, results); @@ -884,14 +886,30 @@ const prefix = "google_photos_"; const downloadError = "Encountered an error while executing downloads."; const requestError = "Unable to execute download: the body's media items were malformed."; +app.get("/gapiCleanup", (req, res) => { + write_text_file(file, ""); + res.redirect(RouteStore.delete); +}); + +const file = "./apis/google/existing_uploads.json"; app.post(RouteStore.googlePhotosMediaDownload, async (req, res) => { const contents: { mediaItems: MediaItem[] } = req.body; if (contents) { - const pending = contents.mediaItems.map(item => - UploadUtils.UploadImage(item.baseUrl, item.filename, prefix) - ); - const completed = await Promise.all(pending).catch(error => _error(res, downloadError, error)); - Array.isArray(completed) && _success(res, completed); + const completed: Opt<UploadUtils.UploadInformation>[] = []; + const content = await read_text_file(file); + let existing = content.length ? JSON.parse(content) : {}; + for (let item of contents.mediaItems) { + const { contentSize, ...attributes } = await UploadUtils.InspectImage(item.baseUrl); + const found: UploadUtils.UploadInformation = existing[contentSize]; + if (!found) { + const upload = await UploadUtils.UploadImage({ contentSize, ...attributes }, item.filename, prefix).catch(error => _error(res, downloadError, error)); + upload && completed.push(existing[contentSize] = upload); + } else { + completed.push(found); + } + } + await write_text_file(file, JSON.stringify(existing)); + _success(res, completed); return; } _invalid(res, requestError); |