From 2b829d1028a61869858ecd48e2f1801819e17488 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Tue, 13 Aug 2019 22:15:47 -0400 Subject: robust google doc export api and cleanup --- src/new_fields/RichTextField.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'src/new_fields') diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 89799b2af..dc66813e0 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -9,6 +9,7 @@ import { scriptingGlobal } from "../client/util/Scripting"; export class RichTextField extends ObjectField { @serializable(true) readonly Data: string; + private Extractor = /,\"text\":\"([^\"\}]*)\"\}/g; constructor(data: string) { super(); @@ -22,4 +23,16 @@ export class RichTextField extends ObjectField { [ToScriptString]() { return `new RichTextField("${this.Data}")`; } + + plainText = () => { + let contents = ""; + let matches: RegExpExecArray | null; + let considering = this.Data; + while ((matches = this.Extractor.exec(considering)) !== null) { + contents += matches[1]; + considering = considering.substring(matches.index + matches[0].length); + this.Extractor.lastIndex = 0; + } + return contents.length ? contents : undefined; + } } \ No newline at end of file -- cgit v1.2.3-70-g09d2 From dc766185075b5861686c68a704a8e49213eacbc6 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 01:00:29 -0400 Subject: cleanup and streamlined / robust type requirements --- .../apis/google_docs/GoogleApiClientUtils.ts | 88 +++++++++++----------- src/client/views/MainView.tsx | 12 --- src/client/views/nodes/FormattedTextBox.tsx | 20 ++--- src/new_fields/RichTextField.ts | 2 +- 4 files changed, 58 insertions(+), 64 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index dda36f05a..f4fb87e0b 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -17,32 +17,32 @@ export namespace GoogleApiClientUtils { export namespace Utils { export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false) => { - let fragments: string[] = []; + const fragments: string[] = []; if (document.body && document.body.content) { - for (let element of document.body.content) { + for (const element of document.body.content) { if (element.paragraph && element.paragraph.elements) { - for (let inner of element.paragraph.elements) { + for (const inner of element.paragraph.elements) { if (inner && inner.textRun) { - let fragment = inner.textRun.content; + const fragment = inner.textRun.content; fragment && fragments.push(fragment); } } } } } - let text = fragments.join(""); + const text = fragments.join(""); return removeNewlines ? text.ReplaceAll("\n", "") : text; }; export const EndOf = (schema: docs_v1.Schema$Document): Opt => { if (schema.body && schema.body.content) { - let paragraphs = schema.body.content.filter(el => el.paragraph); + const paragraphs = schema.body.content.filter(el => el.paragraph); if (paragraphs.length) { - let target = paragraphs[paragraphs.length - 1]; + const target = paragraphs[paragraphs.length - 1]; if (target.paragraph && target.paragraph.elements) { length = target.paragraph.elements.length; if (length) { - let final = target.paragraph.elements[length - 1]; + const final = target.paragraph.elements[length - 1]; return final.endIndex ? final.endIndex - 1 : undefined; } } @@ -52,17 +52,25 @@ export namespace GoogleApiClientUtils { } + export type IdHandler = (id: DocumentId) => any; + export interface CreateOptions { + handler: IdHandler; + // if excluded, will use a default title annotated with the current date + title?: string; + } + export interface ReadOptions { documentId: string; + // if exluded, will preserve newlines removeNewlines?: boolean; } + export type DocumentId = string; export interface WriteOptions { - documentId?: string; - title?: string; content: string | string[]; + reference: DocumentId | CreateOptions; + // if excluded, will compute the last index of the document and append the content there index?: number; - store?: { receiver: Doc, key: string }; } /** @@ -70,33 +78,36 @@ export namespace GoogleApiClientUtils { * 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 schema whatever subset of a docs_v1.Schema$Document is required to properly initialize your - * Google Doc. This schema defines all aspects of a Google Doc, from the title to headers / footers to the - * actual document body and its styling! + * @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. */ - const Create = async (title?: string): Promise => { - let path = RouteStore.googleDocs + Actions.Create; - let parameters = { + const Create = async (options: CreateOptions): Promise => { + const path = RouteStore.googleDocs + Actions.Create; + const parameters = { requestBody: { - title: title || `Dash Export (${new Date().toDateString()})` + title: options.title || `Dash Export (${new Date().toDateString()})` } }; try { - let schema: docs_v1.Schema$Document = await PostToServer(path, parameters); - return schema.documentId; + const schema: docs_v1.Schema$Document = await PostToServer(path, parameters); + const generatedId = schema.documentId; + if (generatedId) { + options.handler(generatedId); + return generatedId; + } } catch { return undefined; } }; const Retrieve = async (documentId: string): Promise => { - let path = RouteStore.googleDocs + Actions.Retrieve; - let parameters = { + const path = RouteStore.googleDocs + Actions.Retrieve; + const parameters = { documentId }; try { - let schema: docs_v1.Schema$Document = await PostToServer(path, parameters); + const schema: docs_v1.Schema$Document = await PostToServer(path, parameters); return schema; } catch { return undefined; @@ -104,16 +115,15 @@ export namespace GoogleApiClientUtils { }; const Update = async (documentId: string, requests: docs_v1.Schema$Request[]): Promise => { - let path = RouteStore.googleDocs + Actions.Update; - let parameters = { + const path = RouteStore.googleDocs + Actions.Update; + const parameters = { documentId, requestBody: { requests } }; try { - let replies: docs_v1.Schema$BatchUpdateDocumentResponse = await PostToServer(path, parameters); - console.log(replies); + const replies: docs_v1.Schema$BatchUpdateDocumentResponse = await PostToServer(path, parameters); return replies; } catch { return undefined; @@ -131,38 +141,32 @@ export namespace GoogleApiClientUtils { if (!schema) { return undefined; } - let lines = Utils.extractText(schema).split("\n"); + const lines = Utils.extractText(schema).split("\n"); return options.removeNewlines ? lines.filter(line => line.length) : lines; }); }; export const Write = async (options: WriteOptions): Promise => { - let target = options.documentId; - if (!target) { - if (!(target = await Create(options.title))) { - return undefined; - } + let documentId: string | undefined; + const ref = options.reference; + if (!(documentId = typeof ref === "string" ? ref : await Create(ref))) { + return undefined; } let index = options.index; if (!index) { - let schema = await Retrieve(target); + let schema = await Retrieve(documentId); if (!schema || !(index = Utils.EndOf(schema))) { return undefined; } } - let text = options.content; - let request = { + const text = options.content; + const request = { insertText: { text: isArray(text) ? text.join("\n") : text, location: { index } } }; - return Update(target, [request]).then(res => { - if (res && options.store) { - options.store.receiver[options.store.key] = res.documentId; - } - return res; - }); + return Update(documentId, [request]); }; } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 7b15e9624..77f0e3d60 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -149,18 +149,6 @@ export class MainView extends React.Component { }, { fireImmediately: true }); } - componentDidMount() { - reaction(() => this.mainContainer, () => { - let main = this.mainContainer, documentId; - if (main && (documentId = StrCast(main.googleDocId))) { - let options = { documentId, removeNewlines: true }; - GoogleApiClientUtils.Docs.ReadLines(options).then(lines => { - console.log(lines); - }); - } - }); - } - componentWillUnMount() { window.removeEventListener("keydown", KeyManager.Instance.handle); window.removeEventListener("pointerdown", this.globalPointerDown); diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 8c2af7c9e..50ec27259 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -670,16 +670,18 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } exportToGoogleDoc = () => { - let dataDoc = Doc.GetProto(this.props.Document); - let data = Cast(dataDoc.data, RichTextField); - let content: string | undefined; - if (data && (content = data.plainText())) { - GoogleApiClientUtils.Docs.Write({ - title: StrCast(dataDoc.title), - store: { receiver: dataDoc, key: googleDocKey }, - content - }); + const dataDoc = Doc.GetProto(this.props.Document); + const data = Cast(dataDoc.data, RichTextField); + if (!data) { + return; } + GoogleApiClientUtils.Docs.Write({ + reference: { + title: StrCast(dataDoc.title), + handler: id => dataDoc[googleDocKey] = id + }, + content: data.plainText() + }); } render() { diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index dc66813e0..3e8803a34 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -33,6 +33,6 @@ export class RichTextField extends ObjectField { considering = considering.substring(matches.index + matches[0].length); this.Extractor.lastIndex = 0; } - return contents.length ? contents : undefined; + return contents; } } \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 48fcec82fa384ec260a02965f9f78c2e41256dd9 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 03:41:11 -0400 Subject: clean up and regex improvement --- .../apis/google_docs/GoogleApiClientUtils.ts | 111 ++++++++++++--------- src/client/views/nodes/FormattedTextBox.tsx | 15 ++- src/new_fields/RichTextField.ts | 4 +- src/server/index.ts | 35 ++++--- 4 files changed, 99 insertions(+), 66 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index f4fb87e0b..c6c7d7bd4 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -1,7 +1,7 @@ import { docs_v1 } from "googleapis"; import { PostToServer } from "../../../Utils"; import { RouteStore } from "../../../server/RouteStore"; -import { Opt, Doc } from "../../../new_fields/Doc"; +import { Opt } from "../../../new_fields/Doc"; import { isArray } from "util"; export namespace GoogleApiClientUtils { @@ -14,9 +14,42 @@ export namespace GoogleApiClientUtils { Update = "update" } + export type DocumentId = string; + export type Reference = DocumentId | CreateOptions; + export type TextContent = string | string[]; + export type IdHandler = (id: DocumentId) => any; + + export type CreationResult = Opt; + export type RetrievalResult = Opt; + export type UpdateResult = Opt; + export type ReadLinesResult = Opt; + export type ReadResult = Opt; + + export interface CreateOptions { + handler: IdHandler; // callback to process the documentId of the newly created Google Doc + title?: string; // if excluded, will use a default title annotated with the current date + } + + export interface RetrieveOptions { + documentId: DocumentId; + } + + export type ReadOptions = RetrieveOptions & { removeNewlines?: boolean }; + + export interface WriteOptions { + content: TextContent; + reference: Reference; + index?: number; // if excluded, will compute the last index of the document and append the content there + } + + export interface UpdateOptions { + documentId: DocumentId; + requests: docs_v1.Schema$Request[]; + } + export namespace Utils { - export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false) => { + export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => { const fragments: string[] = []; if (document.body && document.body.content) { for (const element of document.body.content) { @@ -34,7 +67,7 @@ export namespace GoogleApiClientUtils { return removeNewlines ? text.ReplaceAll("\n", "") : text; }; - export const EndOf = (schema: docs_v1.Schema$Document): Opt => { + export const endOf = (schema: docs_v1.Schema$Document): number | undefined => { if (schema.body && schema.body.content) { const paragraphs = schema.body.content.filter(el => el.paragraph); if (paragraphs.length) { @@ -50,27 +83,8 @@ export namespace GoogleApiClientUtils { } }; - } + export const initialize = async (reference: Reference) => typeof reference === "string" ? reference : create(reference); - export type IdHandler = (id: DocumentId) => any; - export interface CreateOptions { - handler: IdHandler; - // if excluded, will use a default title annotated with the current date - title?: string; - } - - export interface ReadOptions { - documentId: string; - // if exluded, will preserve newlines - removeNewlines?: boolean; - } - - export type DocumentId = string; - export interface WriteOptions { - content: string | string[]; - reference: DocumentId | CreateOptions; - // if excluded, will compute the last index of the document and append the content there - index?: number; } /** @@ -82,7 +96,7 @@ export namespace GoogleApiClientUtils { * 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. */ - const Create = async (options: CreateOptions): Promise => { + export const create = async (options: CreateOptions): Promise => { const path = RouteStore.googleDocs + Actions.Create; const parameters = { requestBody: { @@ -101,43 +115,40 @@ export namespace GoogleApiClientUtils { } }; - const Retrieve = async (documentId: string): Promise => { + export const retrieve = async (options: RetrieveOptions): Promise => { const path = RouteStore.googleDocs + Actions.Retrieve; - const parameters = { - documentId - }; try { - const schema: docs_v1.Schema$Document = await PostToServer(path, parameters); + const schema: RetrievalResult = await PostToServer(path, options); return schema; } catch { return undefined; } }; - const Update = async (documentId: string, requests: docs_v1.Schema$Request[]): Promise => { + export const update = async (options: UpdateOptions): Promise => { const path = RouteStore.googleDocs + Actions.Update; const parameters = { - documentId, + documentId: options.documentId, requestBody: { - requests + requests: options.requests } }; try { - const replies: docs_v1.Schema$BatchUpdateDocumentResponse = await PostToServer(path, parameters); + const replies: UpdateResult = await PostToServer(path, parameters); return replies; } catch { return undefined; } }; - export const Read = async (options: ReadOptions): Promise => { - return Retrieve(options.documentId).then(schema => { + export const read = async (options: ReadOptions): Promise => { + return retrieve(options).then(schema => { return schema ? Utils.extractText(schema, options.removeNewlines) : undefined; }); }; - export const ReadLines = async (options: ReadOptions) => { - return Retrieve(options.documentId).then(schema => { + export const readLines = async (options: ReadOptions): Promise => { + return retrieve(options).then(schema => { if (!schema) { return undefined; } @@ -146,27 +157,29 @@ export namespace GoogleApiClientUtils { }); }; - export const Write = async (options: WriteOptions): Promise => { - let documentId: string | undefined; - const ref = options.reference; - if (!(documentId = typeof ref === "string" ? ref : await Create(ref))) { + export const write = async (options: WriteOptions): Promise => { + const documentId = await Utils.initialize(options.reference); + if (!documentId) { return undefined; } let index = options.index; if (!index) { - let schema = await Retrieve(documentId); - if (!schema || !(index = Utils.EndOf(schema))) { + let schema = await retrieve({ documentId }); + if (!schema || !(index = Utils.endOf(schema))) { return undefined; } } const text = options.content; - const request = { - insertText: { - text: isArray(text) ? text.join("\n") : text, - location: { index } - } + const updateOptions = { + documentId, + requests: [{ + insertText: { + text: isArray(text) ? text.join("\n") : text, + location: { index } + } + }] }; - return Update(documentId, [request]); + return update(updateOptions); }; } diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 50ec27259..46aed9b2d 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -288,6 +288,19 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } + componentWillMount() { + this.pollExportedCounterpart(); + } + + pollExportedCounterpart = async () => { + let dataDoc = Doc.GetProto(this.props.Document); + let documentId = StrCast(dataDoc[googleDocKey]); + if (documentId) { + let contents = await GoogleApiClientUtils.Docs.read({ documentId }); + contents ? console.log(contents) : delete dataDoc[googleDocKey]; + } + } + componentDidMount() { const config = { schema, @@ -675,7 +688,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (!data) { return; } - GoogleApiClientUtils.Docs.Write({ + GoogleApiClientUtils.Docs.write({ reference: { title: StrCast(dataDoc.title), handler: id => dataDoc[googleDocKey] = id diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 3e8803a34..4f782816c 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -9,7 +9,7 @@ import { scriptingGlobal } from "../client/util/Scripting"; export class RichTextField extends ObjectField { @serializable(true) readonly Data: string; - private Extractor = /,\"text\":\"([^\"\}]*)\"\}/g; + private Extractor = /,\"text\":\"([^\}]*)\"\}/g; constructor(data: string) { super(); @@ -33,6 +33,6 @@ export class RichTextField extends ObjectField { considering = considering.substring(matches.index + matches[0].length); this.Extractor.lastIndex = 0; } - return contents; + return contents.ReplaceAll("\\", ""); } } \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index abaa29658..ef1829f30 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -43,6 +43,8 @@ import * as YoutubeApi from "./apis/youtube/youtubeApiSample"; import { Response } from 'express-serve-static-core'; import { GoogleApiServerUtils } from "./apis/google/GoogleApiServerUtils"; import { GaxiosResponse } from 'gaxios'; +import { Opt } from '../new_fields/Doc'; +import { docs_v1 } from 'googleapis'; const MongoStore = require('connect-mongo')(session); const mongoose = require('mongoose'); const probe = require("probe-image-size"); @@ -797,23 +799,28 @@ function HandleYoutubeQuery([query, callback]: [YoutubeQueryInput, (result?: any const credentials = path.join(__dirname, "./credentials/google_docs_credentials.json"); const token = path.join(__dirname, "./credentials/google_docs_token.json"); +type ApiResponse = Promise; +type ApiHandler = (endpoint: docs_v1.Resource$Documents, parameters: any) => ApiResponse; +type Action = "create" | "retrieve" | "update"; + +const EndpointHandlerMap = new Map([ + ["create", (api, params) => api.create(params)], + ["retrieve", (api, params) => api.get(params)], + ["update", (api, params) => api.batchUpdate(params)], +]); + app.post(RouteStore.googleDocs + ":action", (req, res) => { GoogleApiServerUtils.Docs.GetEndpoint({ credentials, token }).then(endpoint => { - let results: Promise | undefined; - let documents = endpoint.documents; - let parameters = req.body; - switch (req.params.action) { - case "create": - results = documents.create(parameters); - break; - case "retrieve": - results = documents.get(parameters); - break; - case "update": - results = documents.batchUpdate(parameters); - break; + let handler = EndpointHandlerMap.get(req.params.action); + if (handler) { + let execute = handler(endpoint.documents, req.body).then( + response => res.send(response.data), + rejection => res.send(rejection) + ); + execute.catch(exception => res.send(exception)); + return; } - !results ? res.send(undefined) : results.then(response => res.send(response.data)); + res.send(undefined); }); }); -- cgit v1.2.3-70-g09d2 From 9ea032b9ab14ba17511b1014044dba0236e93837 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 10:48:23 -0400 Subject: refactored plain text parsing and setting --- src/client/views/nodes/FormattedTextBox.tsx | 13 +++++--- src/new_fields/RichTextField.ts | 50 ++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 15 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 46aed9b2d..5ba2aa0cf 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -12,7 +12,7 @@ import { EditorView } from "prosemirror-view"; import { Doc, Opt, DocListCast } from "../../../new_fields/Doc"; import { Id, Copy } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; -import { RichTextField } from "../../../new_fields/RichTextField"; +import { RichTextField, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField"; import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema"; import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { DocServer } from "../../DocServer"; @@ -296,8 +296,13 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let dataDoc = Doc.GetProto(this.props.Document); let documentId = StrCast(dataDoc[googleDocKey]); if (documentId) { - let contents = await GoogleApiClientUtils.Docs.read({ documentId }); - contents ? console.log(contents) : delete dataDoc[googleDocKey]; + let exportState = await GoogleApiClientUtils.Docs.read({ documentId }); + if (exportState) { + let data = Cast(dataDoc.data, RichTextField); + data && data[FromPlainText](exportState); + } else { + delete dataDoc[googleDocKey]; + } } } @@ -693,7 +698,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe title: StrCast(dataDoc.title), handler: id => dataDoc[googleDocKey] = id }, - content: data.plainText() + content: data[ToPlainText]() }); } diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 4f782816c..9d8a1cecb 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -4,12 +4,14 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { Copy, ToScriptString } from "./FieldSymbols"; import { scriptingGlobal } from "../client/util/Scripting"; +export const ToPlainText = Symbol("PlainText"); +export const FromPlainText = Symbol("PlainText"); + @scriptingGlobal @Deserializable("RichTextField") export class RichTextField extends ObjectField { @serializable(true) - readonly Data: string; - private Extractor = /,\"text\":\"([^\}]*)\"\}/g; + Data: string; constructor(data: string) { super(); @@ -24,15 +26,41 @@ export class RichTextField extends ObjectField { return `new RichTextField("${this.Data}")`; } - plainText = () => { - let contents = ""; - let matches: RegExpExecArray | null; - let considering = this.Data; - while ((matches = this.Extractor.exec(considering)) !== null) { - contents += matches[1]; - considering = considering.substring(matches.index + matches[0].length); - this.Extractor.lastIndex = 0; + [ToPlainText]() { + let content = JSON.parse(this.Data).doc.content; + let paragraphs = content.filter((item: any) => item.type === "paragraph"); + let output = ""; + for (let i = 0; i < paragraphs.length; i++) { + let paragraph = paragraphs[i]; + if (paragraph.content) { + output += paragraph.content.map((block: any) => block.text).join(""); + } else { + output += i > 0 && paragraphs[i - 1].content ? "\n\n" : "\n"; + } } - return contents.ReplaceAll("\\", ""); + return output; + } + + [FromPlainText](plainText: string) { + let elements = plainText.split("\n"); + let parsed = JSON.parse(this.Data); + parsed.doc.content = elements.map(text => { + let paragraph: any = { type: "paragraph" }; + if (text.length) { + paragraph.content = [{ + type: "text", + marks: [], + text + }]; + } + return paragraph; + }); + parsed.selection = { + type: "text", + anchor: 0, + head: 0 + }; + this.Data = JSON.stringify(parsed); } + } \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 1b9ee607e0c34f82ef39da494329d24623debd18 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 10:58:26 -0400 Subject: changed selection --- src/new_fields/RichTextField.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src/new_fields') diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 9d8a1cecb..f0284b1b8 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -57,8 +57,8 @@ export class RichTextField extends ObjectField { }); parsed.selection = { type: "text", - anchor: 0, - head: 0 + anchor: plainText.length, + head: plainText.length }; this.Data = JSON.stringify(parsed); } -- cgit v1.2.3-70-g09d2 From 13d2be7f66c01ee9f09cb65f567eb3da760df670 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 15:18:13 -0400 Subject: semi-synchronous editing between dash notes and google docs --- package.json | 2 + .../apis/google_docs/GoogleApiClientUtils.ts | 40 ++++++++++++++------ src/client/views/nodes/FormattedTextBox.tsx | 44 ++++++++++++++-------- src/new_fields/RichTextField.ts | 25 +++++++----- 4 files changed, 76 insertions(+), 35 deletions(-) (limited to 'src/new_fields') diff --git a/package.json b/package.json index 326468ab9..7a629d813 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@types/cookie-parser": "^1.4.1", "@types/cookie-session": "^2.0.36", "@types/d3-format": "^1.3.1", + "@types/diff": "^4.0.2", "@types/dotenv": "^6.1.1", "@types/express": "^4.16.1", "@types/express-flash": "0.0.0", @@ -127,6 +128,7 @@ "cookie-session": "^2.0.0-beta.3", "crypto-browserify": "^3.11.0", "d3-format": "^1.3.2", + "diff": "^4.0.1", "dotenv": "^8.0.0", "express": "^4.16.4", "express-flash": "0.0.2", diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index c6c7d7bd4..c1cbba31f 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -14,6 +14,11 @@ export namespace GoogleApiClientUtils { Update = "update" } + export enum WriteMode { + Insert, + Replace + } + export type DocumentId = string; export type Reference = DocumentId | CreateOptions; export type TextContent = string | string[]; @@ -37,6 +42,7 @@ export namespace GoogleApiClientUtils { export type ReadOptions = RetrieveOptions & { removeNewlines?: boolean }; 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 @@ -158,28 +164,40 @@ export namespace GoogleApiClientUtils { }; export const write = async (options: WriteOptions): Promise => { + const requests: docs_v1.Schema$Request[] = []; const documentId = await Utils.initialize(options.reference); if (!documentId) { return undefined; } let index = options.index; - if (!index) { + const mode = options.mode; + if (!(index && mode === WriteMode.Insert)) { let schema = await retrieve({ documentId }); if (!schema || !(index = Utils.endOf(schema))) { return undefined; } } - const text = options.content; - const updateOptions = { - documentId, - requests: [{ - insertText: { - text: isArray(text) ? text.join("\n") : text, - location: { index } + if (mode === WriteMode.Replace) { + requests.push({ + deleteContentRange: { + range: { + startIndex: 1, + endIndex: index + } } - }] - }; - return update(updateOptions); + }); + index = 1; + } + const text = options.content; + requests.push({ + insertText: { + text: isArray(text) ? text.join("\n") : text, + location: { index } + } + }); + let replies = await update({ documentId, requests }); + console.log(replies); + return replies; }; } diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 5ba2aa0cf..ad29ce775 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -12,7 +12,7 @@ import { EditorView } from "prosemirror-view"; import { Doc, Opt, DocListCast } from "../../../new_fields/Doc"; import { Id, Copy } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; -import { RichTextField, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField"; +import { RichTextField, ToGoogleDocText, FromGoogleDocText } from "../../../new_fields/RichTextField"; import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema"; import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { DocServer } from "../../DocServer"; @@ -39,6 +39,7 @@ import { DateField } from '../../../new_fields/DateField'; import { Utils } from '../../../Utils'; import { MainOverlayTextBox } from '../MainOverlayTextBox'; import { GoogleApiClientUtils } from '../../apis/google_docs/GoogleApiClientUtils'; +import * as diff from "diff"; library.add(faEdit); library.add(faSmile, faTextHeight, faUpload); @@ -299,7 +300,9 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let exportState = await GoogleApiClientUtils.Docs.read({ documentId }); if (exportState) { let data = Cast(dataDoc.data, RichTextField); - data && data[FromPlainText](exportState); + if (data) { + dataDoc.data = new RichTextField(data[FromGoogleDocText](exportState)); + } } else { delete dataDoc[googleDocKey]; } @@ -629,6 +632,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe this._undoTyping.end(); this._undoTyping = undefined; } + this.updateGoogleDoc(); } public _undoTyping?: UndoManager.Batch; onKeyPress = (e: React.KeyboardEvent) => { @@ -683,23 +687,33 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe }); ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: subitems, icon: "text-height" }); if (!(googleDocKey in Doc.GetProto(this.props.Document))) { - ContextMenu.Instance.addItem({ description: "Export to Google Doc...", event: this.exportToGoogleDoc, icon: "upload" }); + ContextMenu.Instance.addItem({ + description: "Export to Google Doc...", + event: this.updateGoogleDoc, + icon: "upload" + }); } } - exportToGoogleDoc = () => { - const dataDoc = Doc.GetProto(this.props.Document); - const data = Cast(dataDoc.data, RichTextField); - if (!data) { - return; + updateGoogleDoc = () => { + let modes = GoogleApiClientUtils.Docs.WriteMode; + let mode = modes.Replace; + let reference: Opt = Cast(this.dataDoc[googleDocKey], "string"); + if (!reference) { + mode = modes.Insert; + reference = { + title: StrCast(this.dataDoc.title), + handler: id => this.dataDoc[googleDocKey] = id + }; + } + const data = Cast(this.dataDoc.data, RichTextField); + if (data) { + GoogleApiClientUtils.Docs.write({ + mode, + content: data[ToGoogleDocText](), + reference + }); } - GoogleApiClientUtils.Docs.write({ - reference: { - title: StrCast(dataDoc.title), - handler: id => dataDoc[googleDocKey] = id - }, - content: data[ToPlainText]() - }); } render() { diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index f0284b1b8..92b19b921 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -4,14 +4,14 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { Copy, ToScriptString } from "./FieldSymbols"; import { scriptingGlobal } from "../client/util/Scripting"; -export const ToPlainText = Symbol("PlainText"); -export const FromPlainText = Symbol("PlainText"); +export const ToGoogleDocText = Symbol("PlainText"); +export const FromGoogleDocText = Symbol("PlainText"); @scriptingGlobal @Deserializable("RichTextField") export class RichTextField extends ObjectField { @serializable(true) - Data: string; + readonly Data: string; constructor(data: string) { super(); @@ -26,24 +26,28 @@ export class RichTextField extends ObjectField { return `new RichTextField("${this.Data}")`; } - [ToPlainText]() { + [ToGoogleDocText]() { let content = JSON.parse(this.Data).doc.content; let paragraphs = content.filter((item: any) => item.type === "paragraph"); let output = ""; for (let i = 0; i < paragraphs.length; i++) { let paragraph = paragraphs[i]; + let addNewLine = i > 0 ? paragraphs[i - 1].content : false; if (paragraph.content) { output += paragraph.content.map((block: any) => block.text).join(""); } else { - output += i > 0 && paragraphs[i - 1].content ? "\n\n" : "\n"; + output += "\n"; } + addNewLine && (output += "\n"); } return output; } - [FromPlainText](plainText: string) { + [FromGoogleDocText](plainText: string) { let elements = plainText.split("\n"); + !elements[elements.length - 1].length && elements.pop(); let parsed = JSON.parse(this.Data); + let blankCount = 0; parsed.doc.content = elements.map(text => { let paragraph: any = { type: "paragraph" }; if (text.length) { @@ -52,15 +56,18 @@ export class RichTextField extends ObjectField { marks: [], text }]; + } else { + blankCount++; } return paragraph; }); + let selection = plainText.length + 2 * blankCount; parsed.selection = { type: "text", - anchor: plainText.length, - head: plainText.length + anchor: selection, + head: selection }; - this.Data = JSON.stringify(parsed); + return JSON.stringify(parsed); } } \ No newline at end of file -- cgit v1.2.3-70-g09d2 From 4f338a06345c6a002c9bebc9ad0c9c1fe910eebd Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Wed, 14 Aug 2019 16:15:56 -0400 Subject: clean up and edge cases --- src/client/apis/google_docs/GoogleApiClientUtils.ts | 15 +++++++++++---- src/new_fields/RichTextField.ts | 10 +++------- 2 files changed, 14 insertions(+), 11 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index c1cbba31f..9d78b632d 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -178,7 +178,7 @@ export namespace GoogleApiClientUtils { } } if (mode === WriteMode.Replace) { - requests.push({ + index > 1 && requests.push({ deleteContentRange: { range: { startIndex: 1, @@ -189,14 +189,21 @@ export namespace GoogleApiClientUtils { index = 1; } const text = options.content; - requests.push({ + text.length && requests.push({ insertText: { text: isArray(text) ? text.join("\n") : text, location: { index } } }); - let replies = await update({ documentId, requests }); - console.log(replies); + if (!requests.length) { + return undefined; + } + let replies: any = await update({ documentId, requests }); + let errors = "errors"; + if (errors in replies) { + console.log("Write operation failed:"); + console.log(replies[errors].map((error: any) => error.message)); + } return replies; }; diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 92b19b921..bd24bad1a 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -36,7 +36,7 @@ export class RichTextField extends ObjectField { if (paragraph.content) { output += paragraph.content.map((block: any) => block.text).join(""); } else { - output += "\n"; + output += i === 0 ? "" : "\n"; } addNewLine && (output += "\n"); } @@ -47,7 +47,6 @@ export class RichTextField extends ObjectField { let elements = plainText.split("\n"); !elements[elements.length - 1].length && elements.pop(); let parsed = JSON.parse(this.Data); - let blankCount = 0; parsed.doc.content = elements.map(text => { let paragraph: any = { type: "paragraph" }; if (text.length) { @@ -56,16 +55,13 @@ export class RichTextField extends ObjectField { marks: [], text }]; - } else { - blankCount++; } return paragraph; }); - let selection = plainText.length + 2 * blankCount; parsed.selection = { type: "text", - anchor: selection, - head: selection + anchor: 1, + head: 1 }; return JSON.stringify(parsed); } -- cgit v1.2.3-70-g09d2 From 6592660b944d745b7368f478cd9f5d9e778537ed Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Thu, 15 Aug 2019 23:52:39 -0400 Subject: simplified text testing --- src/new_fields/RichTextField.ts | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src/new_fields') diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index bd24bad1a..8963682c3 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -27,6 +27,9 @@ export class RichTextField extends ObjectField { } [ToGoogleDocText]() { + let state = JSON.parse(this.Data); + let text = state.doc.textBetween(0, state.doc.content.size, "\n\n"); + console.log(text); let content = JSON.parse(this.Data).doc.content; let paragraphs = content.filter((item: any) => item.type === "paragraph"); let output = ""; -- cgit v1.2.3-70-g09d2 From 7ea6b44b10e1bf23287ba33e0081cacbfc595780 Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Mon, 19 Aug 2019 14:58:02 -0400 Subject: seemed to fix syncing with remote google doc --- src/client/views/DocumentDecorations.tsx | 14 ++++++++ src/client/views/nodes/FormattedTextBox.tsx | 41 ++++++++++++++--------- src/new_fields/RichTextField.ts | 52 +++++++++++++---------------- 3 files changed, 63 insertions(+), 44 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 6616d5d58..963722fe3 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,5 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faLink, faTag } from '@fortawesome/free-solid-svg-icons'; +import * as fa from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; @@ -36,6 +37,7 @@ export const Flyout = higflyout.default; library.add(faLink); library.add(faTag); +library.add(fa.faGoogleDrive as any); @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { @@ -618,6 +620,17 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> ); } + considerGoogleDoc = () => { + let thisDoc = SelectionManager.SelectedDocuments()[0].props.Document; + let canEmbed = thisDoc.data && thisDoc.data instanceof RichTextField; + if (!canEmbed) return (null); + return ( +
+ +
+ ); + } + considerTooltip = () => { let thisDoc = SelectionManager.SelectedDocuments()[0].props.Document; let isTextDoc = thisDoc.data && thisDoc.data instanceof RichTextField; @@ -768,6 +781,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> {this.metadataMenu} {this.considerEmbed()} + {this.considerGoogleDoc()} {/* {this.considerTooltip()} */} diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index ad29ce775..bc057bb5f 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -60,7 +60,7 @@ const richTextSchema = createSchema({ documentText: "string" }); -const googleDocKey = "googleDocId"; +const googleDocId = "googleDocId"; type RichTextDocument = makeInterface<[typeof richTextSchema]>; const RichTextDocument = makeInterface(richTextSchema); @@ -84,6 +84,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _proxyReactionDisposer: Opt; private dropDisposer?: DragManager.DragDropDisposer; public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } + private isOpening = false; @observable _entered = false; @observable public static InputBoxOverlay?: FormattedTextBox = undefined; @@ -295,17 +296,19 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe pollExportedCounterpart = async () => { let dataDoc = Doc.GetProto(this.props.Document); - let documentId = StrCast(dataDoc[googleDocKey]); + let documentId = StrCast(dataDoc[googleDocId]); if (documentId) { let exportState = await GoogleApiClientUtils.Docs.read({ documentId }); if (exportState) { let data = Cast(dataDoc.data, RichTextField); if (data) { + this.isOpening = true; dataDoc.data = new RichTextField(data[FromGoogleDocText](exportState)); } } else { - delete dataDoc[googleDocKey]; + delete dataDoc[googleDocId]; } + this.tryUpdateHeight(); } } @@ -346,9 +349,18 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined; return field ? field.Data : `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`; }, - field2 => { - this._editorView && !this._applyingChange && - this._editorView.updateState(EditorState.fromJSON(config, JSON.parse(field2))); + incomingValue => { + if (this._editorView && !this._applyingChange) { + let updatedState = JSON.parse(incomingValue); + this._editorView.updateState(EditorState.fromJSON(config, updatedState)); + // manually sets cursor selection at the end of the text on focus + if (this.isOpening) { + this.isOpening = false; + let end = this._editorView.state.doc.content.size - 1; + updatedState.selection = { type: "text", anchor: end, head: end }; + this._editorView.updateState(EditorState.fromJSON(config, updatedState)); + } + } } ); @@ -686,7 +698,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe event: action(() => Doc.GetProto(this.props.Document).autoHeight = !BoolCast(this.props.Document.autoHeight)), icon: "expand-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: subitems, icon: "text-height" }); - if (!(googleDocKey in Doc.GetProto(this.props.Document))) { + if (!(googleDocId in Doc.GetProto(this.props.Document))) { ContextMenu.Instance.addItem({ description: "Export to Google Doc...", event: this.updateGoogleDoc, @@ -698,21 +710,18 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe updateGoogleDoc = () => { let modes = GoogleApiClientUtils.Docs.WriteMode; let mode = modes.Replace; - let reference: Opt = Cast(this.dataDoc[googleDocKey], "string"); + let reference: Opt = Cast(this.dataDoc[googleDocId], "string"); if (!reference) { mode = modes.Insert; reference = { title: StrCast(this.dataDoc.title), - handler: id => this.dataDoc[googleDocKey] = id + handler: id => this.dataDoc[googleDocId] = id }; } - const data = Cast(this.dataDoc.data, RichTextField); - if (data) { - GoogleApiClientUtils.Docs.write({ - mode, - content: data[ToGoogleDocText](), - reference - }); + if (this._editorView) { + let data = Cast(this.dataDoc.data, RichTextField); + let content = data ? data[ToGoogleDocText]() : this._editorView.state.doc.textContent; + GoogleApiClientUtils.Docs.write({ reference, content, mode }); } } diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 8963682c3..ec08293e9 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -6,6 +6,8 @@ import { scriptingGlobal } from "../client/util/Scripting"; export const ToGoogleDocText = Symbol("PlainText"); export const FromGoogleDocText = Symbol("PlainText"); +const delimiter = "\n"; +const joiner = ""; @scriptingGlobal @Deserializable("RichTextField") @@ -27,45 +29,39 @@ export class RichTextField extends ObjectField { } [ToGoogleDocText]() { - let state = JSON.parse(this.Data); - let text = state.doc.textBetween(0, state.doc.content.size, "\n\n"); - console.log(text); + // Because we're working with plain text, just concatenate all paragraphs let content = JSON.parse(this.Data).doc.content; let paragraphs = content.filter((item: any) => item.type === "paragraph"); - let output = ""; - for (let i = 0; i < paragraphs.length; i++) { - let paragraph = paragraphs[i]; - let addNewLine = i > 0 ? paragraphs[i - 1].content : false; - if (paragraph.content) { - output += paragraph.content.map((block: any) => block.text).join(""); - } else { - output += i === 0 ? "" : "\n"; - } - addNewLine && (output += "\n"); - } - return output; + + // Functions to flatten ProseMirror paragraph objects (and their components) to plain text + // While this function already exists in state.doc.textBeteen(), it doesn't account for newlines + let blockText = (block: any) => block.text; + let concatenateParagraph = (p: any) => (p.content ? p.content.map(blockText).join(joiner) : "") + delimiter; + + // Concatentate paragraphs and string the result together. Trim the last newline, an artifact. + let textParagraphs = paragraphs.map(concatenateParagraph); + return textParagraphs.join(joiner).trimEnd(delimiter); } [FromGoogleDocText](plainText: string) { - let elements = plainText.split("\n"); + // Remap the text, creating blocks split on newlines + let elements = plainText.split(delimiter); + + // Google Docs adds in an extra carriage return automatically, so this counteracts it !elements[elements.length - 1].length && elements.pop(); + + // Preserve the current state, but re-write the content to be the blocks let parsed = JSON.parse(this.Data); parsed.doc.content = elements.map(text => { let paragraph: any = { type: "paragraph" }; - if (text.length) { - paragraph.content = [{ - type: "text", - marks: [], - text - }]; - } + text.length && (paragraph.content = [{ type: "text", marks: [], text }]); // An empty paragraph gets treated as a line break return paragraph; }); - parsed.selection = { - type: "text", - anchor: 1, - head: 1 - }; + + // If the new content is shorter than the previous content and selection is unchanged, may throw an out of bounds exception, so we reset it + parsed.selection = { type: "text", anchor: 1, head: 1 }; + + // Export the ProseMirror-compatible state object we've jsut built return JSON.stringify(parsed); } -- cgit v1.2.3-70-g09d2 From 00cecb10d80bad8d717cd06ddc4bf156664b9f2d Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Mon, 19 Aug 2019 22:39:56 -0400 Subject: fixed push pull UI --- .../apis/google_docs/GoogleApiClientUtils.ts | 3 + src/client/views/DocumentDecorations.tsx | 14 ++- src/client/views/nodes/FormattedTextBox.tsx | 122 +++++++++++---------- src/new_fields/RichTextField.ts | 8 +- 4 files changed, 81 insertions(+), 66 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index 55b4a76f8..821c52270 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -4,6 +4,9 @@ import { RouteStore } from "../../../server/RouteStore"; import { Opt } from "../../../new_fields/Doc"; import { isArray } from "util"; +export const Pulls = "googleDocsPullCount"; +export const Pushes = "googleDocsPushCount"; + export namespace GoogleApiClientUtils { export namespace Docs { diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 80d4ecb9b..797b43add 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -30,6 +30,7 @@ import { ObjectField } from '../../new_fields/ObjectField'; import { MetadataEntryMenu } from './MetadataEntryMenu'; import { ImageBox } from './nodes/ImageBox'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; +import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -626,7 +627,10 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (!canPush) return (null); return (
-
thisDoc.pushToGoogleDocsTrigger = !thisDoc.pushToGoogleDocsTrigger}> +
{ + DocumentDecorations.hasPushedHack = false; + thisDoc[Pushes] = NumCast(thisDoc[Pushes]) + 1; + }}>
@@ -639,13 +643,19 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (!canPull) return (null); return (
-
thisDoc.pullFromGoogleDocsTrigger = !thisDoc.pullFromGoogleDocsTrigger}> +
{ + DocumentDecorations.hasPulledHack = false; + thisDoc[Pulls] = NumCast(thisDoc[Pulls]) + 1; + }}>
); } + public static hasPushedHack = false; + public static hasPulledHack = false; + considerTooltip = () => { let thisDoc = SelectionManager.SelectedDocuments()[0].props.Document; let isTextDoc = thisDoc.data && thisDoc.data instanceof RichTextField; diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 406c2c4a6..d2eb71350 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -12,7 +12,7 @@ import { EditorView } from "prosemirror-view"; import { Doc, Opt, DocListCast } from "../../../new_fields/Doc"; import { Id, Copy } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; -import { RichTextField, ToGoogleDocText, FromGoogleDocText } from "../../../new_fields/RichTextField"; +import { RichTextField, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField"; import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema"; import { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { DocServer } from "../../DocServer"; @@ -30,16 +30,14 @@ import { ContextMenu } from "../../views/ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; import { DocComponent } from "../DocComponent"; import { InkingControl } from "../InkingControl"; -import { Templates } from '../Templates'; import { FieldView, FieldViewProps } from "./FieldView"; import "./FormattedTextBox.scss"; import React = require("react"); -import { For } from 'babel-types'; import { DateField } from '../../../new_fields/DateField'; import { Utils } from '../../../Utils'; import { MainOverlayTextBox } from '../MainOverlayTextBox'; -import { GoogleApiClientUtils } from '../../apis/google_docs/GoogleApiClientUtils'; -import * as diff from "diff"; +import { GoogleApiClientUtils, Pulls, Pushes } from '../../apis/google_docs/GoogleApiClientUtils'; +import { DocumentDecorations } from '../DocumentDecorations'; library.add(faEdit); library.add(faSmile, faTextHeight, faUpload); @@ -84,6 +82,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _searchReactionDisposer?: Lambda; private _textReactionDisposer: Opt; private _proxyReactionDisposer: Opt; + private pullReactionDisposer: Opt; + private pushReactionDisposer: Opt; private dropDisposer?: DragManager.DragDropDisposer; public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } private isGoogleDocsUpdate = false; @@ -345,13 +345,25 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } ); - reaction(() => this.props.Document.pullFromGoogleDocsTrigger, () => { - this.pullFromGoogleDoc(); - }); + this.pullReactionDisposer = reaction( + () => this.props.Document[Pulls], + () => { + if (!DocumentDecorations.hasPulledHack) { + DocumentDecorations.hasPulledHack = true; + this.pullFromGoogleDoc(); + } + } + ); - reaction(() => this.props.Document.pushToGoogleDocsTrigger, () => { - this.pushToGoogleDoc(); - }); + this.pushReactionDisposer = reaction( + () => this.props.Document[Pushes], + () => { + if (!DocumentDecorations.hasPushedHack) { + DocumentDecorations.hasPushedHack = true; + this.pushToGoogleDoc(); + } + } + ); this._textReactionDisposer = reaction( () => this.extensionDoc, @@ -380,13 +392,44 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe this.unhighlightSearchTerms(); } }, { fireImmediately: true }); + } - ["pushToGoogleDocsTrigger", "pullFromGoogleDocsTrigger"].map(key => { - let doc = this.props.Document; - if (doc[key] === undefined) { - untracked(() => doc[key] = false); - } - }); + pushToGoogleDoc = () => { + let modes = GoogleApiClientUtils.Docs.WriteMode; + let mode = modes.Replace; + let reference: Opt = Cast(this.dataDoc[googleDocId], "string"); + if (!reference) { + mode = modes.Insert; + reference = { + title: StrCast(this.dataDoc.title), + handler: id => this.dataDoc[googleDocId] = id + }; + } + if (this._editorView) { + let data = Cast(this.dataDoc.data, RichTextField); + let content = data ? data[ToPlainText]() : this._editorView.state.doc.textContent; + GoogleApiClientUtils.Docs.write({ reference, content, mode }); + } + } + + pullFromGoogleDoc = async () => { + let dataDoc = Doc.GetProto(this.props.Document); + let documentId = StrCast(dataDoc[googleDocId]); + if (documentId) { + let exportState = await GoogleApiClientUtils.Docs.read({ documentId }); + UndoManager.RunInBatch(() => { + if (exportState && exportState.body && exportState.title) { + let data = Cast(dataDoc.data, RichTextField); + if (data) { + this.isGoogleDocsUpdate = true; + dataDoc.data = new RichTextField(data[FromPlainText](exportState.body)); + dataDoc.title = exportState.title; + } + } else { + delete dataDoc[googleDocId]; + } + }, Pulls); + } } clipboardTextSerializer = (slice: Slice): string => { @@ -516,6 +559,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe this._reactionDisposer && this._reactionDisposer(); this._proxyReactionDisposer && this._proxyReactionDisposer(); this._textReactionDisposer && this._textReactionDisposer(); + this.pushReactionDisposer && this.pushReactionDisposer(); + this.pullReactionDisposer && this.pullReactionDisposer(); } onPointerDown = (e: React.PointerEvent): void => { @@ -693,49 +738,6 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe event: action(() => Doc.GetProto(this.props.Document).autoHeight = !BoolCast(this.props.Document.autoHeight)), icon: "expand-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: subitems, icon: "text-height" }); - if (!(googleDocId in Doc.GetProto(this.props.Document))) { - ContextMenu.Instance.addItem({ - description: "Export to Google Doc...", - event: this.pushToGoogleDoc, - icon: "upload" - }); - } - } - - pushToGoogleDoc = () => { - let modes = GoogleApiClientUtils.Docs.WriteMode; - let mode = modes.Replace; - let reference: Opt = Cast(this.dataDoc[googleDocId], "string"); - if (!reference) { - mode = modes.Insert; - reference = { - title: StrCast(this.dataDoc.title), - handler: id => this.dataDoc[googleDocId] = id - }; - } - if (this._editorView) { - let data = Cast(this.dataDoc.data, RichTextField); - let content = data ? data[ToGoogleDocText]() : this._editorView.state.doc.textContent; - GoogleApiClientUtils.Docs.write({ reference, content, mode }); - } - } - - pullFromGoogleDoc = async () => { - let dataDoc = Doc.GetProto(this.props.Document); - let documentId = StrCast(dataDoc[googleDocId]); - if (documentId) { - let exportState = await GoogleApiClientUtils.Docs.read({ documentId }); - if (exportState && exportState.body && exportState.title) { - let data = Cast(dataDoc.data, RichTextField); - if (data) { - this.isGoogleDocsUpdate = true; - dataDoc.data = new RichTextField(data[FromGoogleDocText](exportState.body)); - dataDoc.title = exportState.title; - } - } else { - delete dataDoc[googleDocId]; - } - } } diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index ec08293e9..ab58329f9 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -4,8 +4,8 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { Copy, ToScriptString } from "./FieldSymbols"; import { scriptingGlobal } from "../client/util/Scripting"; -export const ToGoogleDocText = Symbol("PlainText"); -export const FromGoogleDocText = Symbol("PlainText"); +export const ToPlainText = Symbol("PlainText"); +export const FromPlainText = Symbol("PlainText"); const delimiter = "\n"; const joiner = ""; @@ -28,7 +28,7 @@ export class RichTextField extends ObjectField { return `new RichTextField("${this.Data}")`; } - [ToGoogleDocText]() { + [ToPlainText]() { // Because we're working with plain text, just concatenate all paragraphs let content = JSON.parse(this.Data).doc.content; let paragraphs = content.filter((item: any) => item.type === "paragraph"); @@ -43,7 +43,7 @@ export class RichTextField extends ObjectField { return textParagraphs.join(joiner).trimEnd(delimiter); } - [FromGoogleDocText](plainText: string) { + [FromPlainText](plainText: string) { // Remap the text, creating blocks split on newlines let elements = plainText.split(delimiter); -- cgit v1.2.3-70-g09d2 From dc587b0fb493d869e6cd38fa94b81105da4fbaab Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Tue, 20 Aug 2019 20:03:08 -0400 Subject: finished UI --- src/client/views/DocumentDecorations.scss | 6 +- src/client/views/DocumentDecorations.tsx | 92 ++++++++++++++++++----------- src/client/views/nodes/FormattedTextBox.tsx | 3 +- src/new_fields/RichTextField.ts | 7 ++- 4 files changed, 67 insertions(+), 41 deletions(-) (limited to 'src/new_fields') diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 0b7411fca..ef7159370 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -255,4 +255,8 @@ $linkGap : 3px; input { margin-right: 10px; } -} \ No newline at end of file +} + +@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } } +@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } } +@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } } \ No newline at end of file diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 05239073b..7645af054 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,5 +1,5 @@ import { library, IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faLink, faTag, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faStopCircle, faCloudUploadAlt } from '@fortawesome/free-solid-svg-icons'; +import { faLink, faTag, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faStopCircle, faCloudUploadAlt, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; @@ -41,8 +41,10 @@ library.add(faArrowAltCircleUp); library.add(faStopCircle); library.add(faCheckCircle); library.add(faCloudUploadAlt); +library.add(faSyncAlt); const cloud: IconProp = "cloud-upload-alt"; +const fetch: IconProp = "sync-alt"; @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { @@ -76,48 +78,47 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> @observable public pushIcon: IconProp = "arrow-alt-circle-up"; @observable public pullIcon: IconProp = "arrow-alt-circle-down"; @observable public pullColor: string = "white"; + @observable public isAnimatingFetch = false; public pullColorAnimating = false; private pullAnimating = false; private pushAnimating = false; public startPullOutcome = action((success: boolean) => { - if (this.pullAnimating) { - return; + if (!this.pullAnimating) { + this.pullAnimating = true; + this.pullIcon = success ? "check-circle" : "stop-circle"; + setTimeout(() => runInAction(() => { + this.pullIcon = "arrow-alt-circle-down"; + this.pullAnimating = false; + }), 1000); } - this.pullAnimating = true; - this.pullIcon = success ? "check-circle" : "stop-circle"; - setTimeout(() => runInAction(() => { - this.pullIcon = "arrow-alt-circle-down"; - this.pullAnimating = false; - }), 1000); }); public startPushOutcome = action((success: boolean) => { - if (this.pushAnimating) { - return; + if (!this.pushAnimating) { + this.pushAnimating = true; + this.pushIcon = success ? "check-circle" : "stop-circle"; + setTimeout(() => runInAction(() => { + this.pushIcon = "arrow-alt-circle-up"; + this.pushAnimating = false; + }), 1000); } - this.pushAnimating = true; - this.pushIcon = success ? "check-circle" : "stop-circle"; - setTimeout(() => runInAction(() => { - this.pushIcon = "arrow-alt-circle-up"; - this.pushAnimating = false; - }), 1000); }); - public setPullState = (unchanged: boolean) => { - if (this.pullColorAnimating) { - return; + public setPullState = action((unchanged: boolean) => { + this.isAnimatingFetch = false; + if (!this.pullColorAnimating) { + this.pullColorAnimating = true; + this.pullColor = unchanged ? "lawngreen" : "red"; + setTimeout(this.clearPullColor, 1000); } - this.pullColorAnimating = true; - this.pullColor = unchanged ? "lawngreen" : "red"; - setTimeout(() => { - runInAction(() => { - this.pullColor = "white"; - this.pullColorAnimating = false; - }); - }, 2000); - } + }); + + private clearPullColor = action(() => { + this.pullColor = "white"; + this.pullColorAnimating = false; + }); constructor(props: Readonly<{}>) { super(props); @@ -679,7 +680,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> let canPush = this.targetDoc.data && this.targetDoc.data instanceof RichTextField; if (!canPush) return (null); let published = Doc.GetProto(this.targetDoc)[GoogleRef] !== undefined; - let icon: IconProp = published ? (this.pushIcon as any) : (cloud as any); + let icon: IconProp = published ? (this.pushIcon as any) : cloud; return (
{ @@ -695,14 +696,33 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> considerGoogleDocsPull = () => { let canPull = this.targetDoc.data && this.targetDoc.data instanceof RichTextField; let dataDoc = Doc.GetProto(this.targetDoc); - if (!canPull || !dataDoc[GoogleRef] || dataDoc.unchanged) return (null); + if (!canPull || !dataDoc[GoogleRef]) return (null); + let icon = !dataDoc.unchanged ? (this.pullIcon as any) : fetch; + let animation = this.isAnimatingFetch ? "spin 0.5s linear infinite" : "none"; return (
-
{ - DocumentDecorations.hasPulledHack = false; - this.targetDoc[Pulls] = NumCast(this.targetDoc[Pulls]) + 1; - }}> - +
{ + this.clearPullColor(); + DocumentDecorations.hasPulledHack = false; + this.targetDoc[Pulls] = NumCast(this.targetDoc[Pulls]) + 1; + dataDoc.unchanged && runInAction(() => this.isAnimatingFetch = true); + }}> +
); diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 8a355a425..e06be7079 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -354,7 +354,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe () => { if (!DocumentDecorations.hasPulledHack) { DocumentDecorations.hasPulledHack = true; - this.pullFromGoogleDoc(this.updateState); + let unchanged = this.dataDoc.unchanged; + this.pullFromGoogleDoc(unchanged ? this.checkState : this.updateState); } } ); diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index ab58329f9..cae5623e6 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -38,9 +38,10 @@ export class RichTextField extends ObjectField { let blockText = (block: any) => block.text; let concatenateParagraph = (p: any) => (p.content ? p.content.map(blockText).join(joiner) : "") + delimiter; - // Concatentate paragraphs and string the result together. Trim the last newline, an artifact. - let textParagraphs = paragraphs.map(concatenateParagraph); - return textParagraphs.join(joiner).trimEnd(delimiter); + // Concatentate paragraphs and string the result together + let textParagraphs: string[] = paragraphs.map(concatenateParagraph); + let plainText = textParagraphs.join(joiner); + return plainText.substring(0, plainText.length - 1); } [FromPlainText](plainText: string) { -- cgit v1.2.3-70-g09d2