diff options
author | Sam Wilkins <35748010+samwilkins333@users.noreply.github.com> | 2019-08-20 21:43:22 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-08-20 21:43:22 -0400 |
commit | 6977fa5b825b4779c9fff0301280706944e88eaa (patch) | |
tree | b19b45a0f2e92ec8593e2ea11b0fb833d45cc216 | |
parent | 700dfc5add1ecd9c2b1ecafcdc593ff821b7a6a6 (diff) | |
parent | 73bb0f572e261850583b698dd819d35a6fe768ec (diff) |
Merge pull request #258 from browngraphicslab/google_docs_api
Google docs api
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | src/client/apis/google_docs/GoogleApiClientUtils.ts | 224 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.scss | 6 | ||||
-rw-r--r-- | src/client/views/DocumentDecorations.tsx | 136 | ||||
-rw-r--r-- | src/client/views/MainView.tsx | 6 | ||||
-rw-r--r-- | src/client/views/collections/CollectionSubView.tsx | 14 | ||||
-rw-r--r-- | src/client/views/nodes/FormattedTextBox.tsx | 148 | ||||
-rw-r--r-- | src/new_fields/RichTextField.ts | 44 | ||||
-rw-r--r-- | src/server/Message.ts | 1 | ||||
-rw-r--r-- | src/server/RouteStore.ts | 3 | ||||
-rw-r--r-- | src/server/apis/google/GoogleApiServerUtils.ts | 109 | ||||
-rw-r--r-- | src/server/apis/youtube/youtubeApiSample.d.ts (renamed from src/server/youtubeApi/youtubeApiSample.d.ts) | 0 | ||||
-rw-r--r-- | src/server/apis/youtube/youtubeApiSample.js (renamed from src/server/youtubeApi/youtubeApiSample.js) | 0 | ||||
-rw-r--r-- | src/server/credentials/google_docs_credentials.json | 1 | ||||
-rw-r--r-- | src/server/credentials/google_docs_token.json | 1 | ||||
-rw-r--r-- | src/server/index.ts | 48 |
16 files changed, 715 insertions, 29 deletions
diff --git a/package.json b/package.json index a4343982e..de1f3f6e6 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/express-session": "^1.15.12", "@types/express-validator": "^3.0.0", "@types/formidable": "^1.0.31", + "@types/gapi": "0.0.39", "@types/jquery": "^3.3.29", "@types/jquery-awesome-cursor": "^0.3.0", "@types/jsonwebtoken": "^8.3.2", @@ -218,4 +219,4 @@ "xoauth2": "^1.2.0", "youtube": "^0.1.0" } -} +}
\ No newline at end of file diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts new file mode 100644 index 000000000..821c52270 --- /dev/null +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -0,0 +1,224 @@ +import { docs_v1 } from "googleapis"; +import { PostToServer } from "../../../Utils"; +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 { + + export enum Actions { + Create = "create", + Retrieve = "retrieve", + Update = "update" + } + + export enum WriteMode { + Insert, + Replace + } + + export type DocumentId = string; + export type Reference = DocumentId | CreateOptions; + export type TextContent = string | string[]; + export type IdHandler = (id: DocumentId) => any; + + export type CreationResult = Opt<DocumentId>; + export type RetrievalResult = Opt<docs_v1.Schema$Document>; + export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>; + export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>; + export type ReadResult = { title?: string, body?: string }; + + 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 { + mode: WriteMode; + 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): string => { + const fragments: string[] = []; + 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); + } + } + } + } + } + const text = fragments.join(""); + return removeNewlines ? text.ReplaceAll("\n", "") : text; + }; + + 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) { + const target = paragraphs[paragraphs.length - 1]; + if (target.paragraph && target.paragraph.elements) { + length = target.paragraph.elements.length; + if (length) { + const final = target.paragraph.elements[length - 1]; + return final.endIndex ? final.endIndex - 1 : undefined; + } + } + } + } + }; + + export const initialize = async (reference: Reference) => typeof reference === "string" ? reference : create(reference); + + } + + /** + * 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 + Actions.Create; + const parameters = { + requestBody: { + title: options.title || `Dash Export (${new Date().toDateString()})` + } + }; + try { + const schema: docs_v1.Schema$Document = await PostToServer(path, parameters); + const generatedId = schema.documentId; + if (generatedId) { + options.handler(generatedId); + return generatedId; + } + } catch { + return undefined; + } + }; + + export const retrieve = async (options: RetrieveOptions): Promise<RetrievalResult> => { + const path = RouteStore.googleDocs + Actions.Retrieve; + try { + const schema: RetrievalResult = await PostToServer(path, options); + return schema; + } catch { + return undefined; + } + }; + + export const update = async (options: UpdateOptions): Promise<UpdateResult> => { + const path = RouteStore.googleDocs + Actions.Update; + const parameters = { + documentId: options.documentId, + requestBody: { + requests: options.requests + } + }; + try { + const replies: UpdateResult = await PostToServer(path, parameters); + return replies; + } catch { + return undefined; + } + }; + + export const read = async (options: ReadOptions): Promise<ReadResult> => { + return retrieve(options).then(document => { + let result: ReadResult = {}; + if (document) { + let title = document.title; + let body = Utils.extractText(document, options.removeNewlines); + result = { title, body }; + } + return result; + }); + }; + + export const readLines = async (options: ReadOptions): Promise<ReadLinesResult> => { + return retrieve(options).then(document => { + let result: ReadLinesResult = {}; + if (document) { + let title = document.title; + let bodyLines = Utils.extractText(document).split("\n"); + options.removeNewlines && (bodyLines = bodyLines.filter(line => line.length)); + result = { title, bodyLines }; + } + return result; + }); + }; + + export const write = async (options: WriteOptions): Promise<UpdateResult> => { + const requests: docs_v1.Schema$Request[] = []; + 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({ documentId }); + if (!schema || !(index = Utils.endOf(schema))) { + return undefined; + } + } + if (mode === WriteMode.Replace) { + index > 1 && requests.push({ + deleteContentRange: { + range: { + startIndex: 1, + endIndex: index + } + } + }); + index = 1; + } + const text = options.content; + text.length && requests.push({ + insertText: { + text: isArray(text) ? text.join("\n") : text, + location: { index } + } + }); + 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; + }; + + } + +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 3627edaae..ac8497bd0 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -264,4 +264,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 aae7f0d3f..891fd7847 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,5 +1,5 @@ -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faLink, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { library, IconProp } from '@fortawesome/fontawesome-svg-core'; +import { faLink, faTag, faTimes, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faStopCircle, faCloudUploadAlt, faSyncAlt, faShare } 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"; @@ -18,7 +18,7 @@ import { CollectionView } from "./collections/CollectionView"; import './DocumentDecorations.scss'; import { DocumentView, PositionDocument } from "./nodes/DocumentView"; import { FieldView } from "./nodes/FieldView"; -import { FormattedTextBox } from "./nodes/FormattedTextBox"; +import { FormattedTextBox, GoogleRef } from "./nodes/FormattedTextBox"; import { IconBox } from "./nodes/IconBox"; import { LinkMenu } from "./nodes/LinkMenu"; import { TemplateMenu } from "./TemplateMenu"; @@ -26,10 +26,10 @@ import { Template, Templates } from "./Templates"; import React = require("react"); import { RichTextField } from '../../new_fields/RichTextField'; import { LinkManager } from '../util/LinkManager'; -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; @@ -37,6 +37,16 @@ export const Flyout = higflyout.default; library.add(faLink); library.add(faTag); library.add(faTimes); +library.add(faArrowAltCircleDown); +library.add(faArrowAltCircleUp); +library.add(faStopCircle); +library.add(faCheckCircle); +library.add(faCloudUploadAlt); +library.add(faSyncAlt); +library.add(faShare); + +const cloud: IconProp = "cloud-upload-alt"; +const fetch: IconProp = "sync-alt"; @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { @@ -68,6 +78,52 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> @observable public Interacting = false; @observable private _isMoving = false; + @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; + @observable public openHover = false; + public pullColorAnimating = false; + + private pullAnimating = false; + private pushAnimating = false; + + public startPullOutcome = action((success: boolean) => { + 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); + } + }); + + public startPushOutcome = action((success: boolean) => { + 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); + } + }); + + public setPullState = action((unchanged: boolean) => { + this.isAnimatingFetch = false; + if (!this.pullColorAnimating) { + this.pullColorAnimating = true; + this.pullColor = unchanged ? "lawngreen" : "red"; + setTimeout(this.clearPullColor, 1000); + } + }); + + private clearPullColor = action(() => { + this.pullColor = "white"; + this.pullColorAnimating = false; + }); + constructor(props: Readonly<{}>) { super(props); DocumentDecorations.Instance = this; @@ -630,6 +686,76 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> ); } + private get targetDoc() { + return SelectionManager.SelectedDocuments()[0].props.Document; + } + + considerGoogleDocsPush = () => { + let canPush = this.targetDoc.data && this.targetDoc.data instanceof RichTextField; + if (!canPush) return (null); + let published = Doc.GetProto(this.targetDoc)[GoogleRef] !== undefined; + if (!published) { + this.targetDoc.autoHeight = true; + } + let icon: IconProp = published ? (this.pushIcon as any) : cloud; + return ( + <div className={"linkButtonWrapper"}> + <div title={`${published ? "Push" : "Publish"} to Google Docs`} className="linkButton-linker" onClick={() => { + DocumentDecorations.hasPushedHack = false; + this.targetDoc[Pushes] = NumCast(this.targetDoc[Pushes]) + 1; + }}> + <FontAwesomeIcon className="documentdecorations-icon" icon={icon} size={published ? "sm" : "xs"} /> + </div> + </div> + ); + } + + considerGoogleDocsPull = () => { + let canPull = this.targetDoc.data && this.targetDoc.data instanceof RichTextField; + let dataDoc = Doc.GetProto(this.targetDoc); + if (!canPull || !dataDoc[GoogleRef]) return (null); + let icon = !dataDoc.unchanged ? (this.pullIcon as any) : fetch; + icon = this.openHover ? "share" : icon; + let animation = this.isAnimatingFetch ? "spin 0.5s linear infinite" : "none"; + let title = `${!dataDoc.unchanged ? "Pull from" : "Fetch"} Google Docs`; + return ( + <div className={"linkButtonWrapper"}> + <div + title={title} + className="linkButton-linker" + style={{ + backgroundColor: this.pullColor, + transition: "0.2s ease all" + }} + onPointerEnter={e => e.ctrlKey && runInAction(() => this.openHover = true)} + onPointerLeave={() => runInAction(() => this.openHover = false)} + onClick={e => { + if (e.ctrlKey) { + window.open(`https://docs.google.com/document/d/${dataDoc[GoogleRef]}/edit`); + } else { + this.clearPullColor(); + DocumentDecorations.hasPulledHack = false; + this.targetDoc[Pulls] = NumCast(this.targetDoc[Pulls]) + 1; + dataDoc.unchanged && runInAction(() => this.isAnimatingFetch = true); + } + }}> + <FontAwesomeIcon + style={{ + WebkitAnimation: animation, + MozAnimation: animation + }} + className="documentdecorations-icon" + icon={icon} + size="sm" + /> + </div> + </div> + ); + } + + public static hasPushedHack = false; + public static hasPulledHack = false; + considerTooltip = () => { let thisDoc = SelectionManager.SelectedDocuments()[0].props.Document; let isTextDoc = thisDoc.data && thisDoc.data instanceof RichTextField; @@ -782,6 +908,8 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> </div> {this.metadataMenu} {this.considerEmbed()} + {this.considerGoogleDocsPush()} + {this.considerGoogleDocsPull()} {/* {this.considerTooltip()} */} </div> </div > diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index b27b91c12..f28844009 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -15,7 +15,7 @@ import { listSpec } from '../../new_fields/Schema'; import { BoolCast, Cast, FieldValue, StrCast } from '../../new_fields/Types'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; import { RouteStore } from '../../server/RouteStore'; -import { emptyFunction, returnEmptyString, returnOne, returnTrue, Utils } from '../../Utils'; +import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString } from '../../Utils'; import { DocServer } from '../DocServer'; import { Docs } from '../documents/Documents'; import { ClientUtils } from '../util/ClientUtils'; @@ -546,8 +546,8 @@ export class MainView extends React.Component { let next = () => PresBox.CurrentPresentation.next(); let back = () => PresBox.CurrentPresentation.back(); let startOrResetPres = () => PresBox.CurrentPresentation.startOrResetPres(); - let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document) }); - return !PresBox.CurrentPresentation || !PresBox.CurrentPresentation.presMode ? (null) : <PresModeMenu next={next} back={back} presStatus={PresBox.CurrentPresentation.presStatus} startOrResetPres={startOrResetPres} closePresMode={closePresMode} > </PresModeMenu> + let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document); }); + return !PresBox.CurrentPresentation || !PresBox.CurrentPresentation.presMode ? (null) : <PresModeMenu next={next} back={back} presStatus={PresBox.CurrentPresentation.presStatus} startOrResetPres={startOrResetPres} closePresMode={closePresMode} > </PresModeMenu>; } render() { diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 818e76619..99e5ab7b3 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -17,7 +17,7 @@ import { DragManager } from "../../util/DragManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DocComponent } from "../DocComponent"; import { FieldViewProps } from "../nodes/FieldView"; -import { FormattedTextBox } from "../nodes/FormattedTextBox"; +import { FormattedTextBox, GoogleRef } from "../nodes/FormattedTextBox"; import { CollectionPDFView } from "./CollectionPDFView"; import { CollectionVideoView } from "./CollectionVideoView"; import { CollectionView } from "./CollectionView"; @@ -207,7 +207,17 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { this.props.addDocument(Docs.Create.VideoDocument(url, { ...options, title: url, width: 400, height: 315, nativeWidth: 600, nativeHeight: 472.5 })); return; } - + let matches: RegExpExecArray | null; + if ((matches = /(https:\/\/)?docs\.google\.com\/document\/d\/([^\\]+)\/edit/g.exec(text)) !== null) { + let newBox = Docs.Create.TextDocument({ ...options, width: 400, height: 200, title: "Awaiting title from Google Docs..." }); + let proto = newBox.proto!; + proto.autoHeight = true; + proto[GoogleRef] = matches[2]; + proto.data = "Please select this document and then click on its pull button to load its contents from from Google Docs..."; + proto.backgroundColor = "#eeeeff"; + this.props.addDocument(newBox); + return; + } let batch = UndoManager.StartBatch("collection view drop"); let promises: Promise<void>[] = []; // tslint:disable-next-line:prefer-for-of diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 0e347ca67..606e8edb0 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEdit, faSmile, faTextHeight } from '@fortawesome/free-solid-svg-icons'; -import { action, computed, IReactionDisposer, Lambda, observable, reaction } from "mobx"; +import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap } from "prosemirror-commands"; import { history } from "prosemirror-history"; @@ -12,9 +12,9 @@ import { DateField } from '../../../new_fields/DateField'; import { Doc, DocListCast, Opt, WidthSym } from "../../../new_fields/Doc"; import { Copy, Id } 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 { BoolCast, Cast, NumCast, StrCast, DateCast } from "../../../new_fields/Types"; import { createSchema, makeInterface } from "../../../new_fields/Schema"; -import { BoolCast, Cast, DateCast, NumCast, StrCast } from "../../../new_fields/Types"; import { Utils } from '../../../Utils'; import { DocServer } from "../../DocServer"; import { Docs, DocUtils } from '../../documents/Documents'; @@ -29,17 +29,21 @@ import { TooltipTextMenu } from "../../util/TooltipTextMenu"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DocComponent } from "../DocComponent"; import { InkingControl } from "../InkingControl"; -import { MainOverlayTextBox } from '../MainOverlayTextBox'; import { FieldView, FieldViewProps } from "./FieldView"; import "./FormattedTextBox.scss"; import React = require("react"); +import { GoogleApiClientUtils, Pulls, Pushes } from '../../apis/google_docs/GoogleApiClientUtils'; +import { DocumentDecorations } from '../DocumentDecorations'; +import { MainOverlayTextBox } from '../MainOverlayTextBox'; library.add(faEdit); -library.add(faSmile, faTextHeight); +library.add(faSmile, faTextHeight, faUpload); // FormattedTextBox: Displays an editable plain text node that maps to a specified Key of a Document // +export const Blank = `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`; + export interface FormattedTextBoxProps { isOverlay?: boolean; hideOnLeave?: boolean; @@ -53,9 +57,13 @@ const richTextSchema = createSchema({ documentText: "string" }); +export const GoogleRef = "googleDocId"; + type RichTextDocument = makeInterface<[typeof richTextSchema]>; const RichTextDocument = makeInterface(richTextSchema); +type PullHandler = (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => void; + @observer export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) { public static LayoutString(fieldStr: string = "data") { @@ -73,8 +81,11 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _textReactionDisposer: Opt<IReactionDisposer>; private _heightReactionDisposer: Opt<IReactionDisposer>; private _proxyReactionDisposer: Opt<IReactionDisposer>; + private pullReactionDisposer: Opt<IReactionDisposer>; + private pushReactionDisposer: Opt<IReactionDisposer>; private dropDisposer?: DragManager.DragDropDisposer; public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } + private isGoogleDocsUpdate = false; @observable _entered = false; @observable public static InputBoxOverlay?: FormattedTextBox = undefined; @@ -286,13 +297,49 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe }, { fireImmediately: true }); } + this.pullFromGoogleDoc(this.checkState); + runInAction(() => DocumentDecorations.Instance.isAnimatingFetch = true); + this._reactionDisposer = reaction( () => { 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}}`; + return field ? field.Data : Blank; }, - 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.isGoogleDocsUpdate) { + this.isGoogleDocsUpdate = 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)); + } + this.tryUpdateHeight(); + } + } + ); + + this.pullReactionDisposer = reaction( + () => this.props.Document[Pulls], + () => { + if (!DocumentDecorations.hasPulledHack) { + DocumentDecorations.hasPulledHack = true; + let unchanged = this.dataDoc.unchanged; + this.pullFromGoogleDoc(unchanged ? this.checkState : this.updateState); + } + } + ); + + this.pushReactionDisposer = reaction( + () => this.props.Document[Pushes], + () => { + if (!DocumentDecorations.hasPushedHack) { + DocumentDecorations.hasPushedHack = true; + this.pushToGoogleDoc(); + } + } ); this._heightReactionDisposer = reaction( @@ -329,6 +376,83 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe }, { fireImmediately: true }); } + pushToGoogleDoc = async () => { + this.pullFromGoogleDoc(async (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => { + let modes = GoogleApiClientUtils.Docs.WriteMode; + let mode = modes.Replace; + let reference: Opt<GoogleApiClientUtils.Docs.Reference> = Cast(this.dataDoc[GoogleRef], "string"); + if (!reference) { + mode = modes.Insert; + reference = { + title: StrCast(this.dataDoc.title), + handler: id => this.dataDoc[GoogleRef] = id + }; + } + let redo = async () => { + let data = Cast(this.dataDoc.data, RichTextField); + if (this._editorView && reference && data) { + let content = data[ToPlainText](); + let response = await GoogleApiClientUtils.Docs.write({ reference, content, mode }); + let pushSuccess = response !== undefined && !("errors" in response); + dataDoc.unchanged = pushSuccess; + DocumentDecorations.Instance.startPushOutcome(pushSuccess); + } + }; + let undo = () => { + let content = exportState.body; + if (reference && content) { + GoogleApiClientUtils.Docs.write({ reference, content, mode }); + } + }; + UndoManager.AddEvent({ undo, redo }); + redo(); + }); + } + + pullFromGoogleDoc = async (handler: PullHandler) => { + let dataDoc = this.dataDoc; + let documentId = StrCast(dataDoc[GoogleRef]); + let exportState: GoogleApiClientUtils.Docs.ReadResult = {}; + if (documentId) { + exportState = await GoogleApiClientUtils.Docs.read({ documentId }); + } + UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); + } + + updateState = (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => { + let pullSuccess = false; + if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) { + let data = Cast(dataDoc.data, RichTextField); + if (data) { + pullSuccess = true; + this.isGoogleDocsUpdate = true; + dataDoc.data = new RichTextField(data[FromPlainText](exportState.body)); + dataDoc.title = exportState.title; + dataDoc.unchanged = true; + } + } else { + delete dataDoc[GoogleRef]; + } + DocumentDecorations.Instance.startPullOutcome(pullSuccess); + this.tryUpdateHeight(); + } + + checkState = (exportState: GoogleApiClientUtils.Docs.ReadResult, dataDoc: Doc) => { + if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) { + let data = Cast(dataDoc.data, RichTextField); + if (data) { + let storedPlainText = data[ToPlainText]() + "\n"; + let receivedPlainText = exportState.body; + let storedTitle = dataDoc.title; + let receivedTitle = exportState.title; + let unchanged = storedPlainText === receivedPlainText && storedTitle === receivedTitle; + dataDoc.unchanged = unchanged; + DocumentDecorations.Instance.setPullState(unchanged); + } + } + } + + clipboardTextSerializer = (slice: Slice): string => { let text = "", separated = true; const from = 0, to = slice.content.size; @@ -456,6 +580,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(); this._heightReactionDisposer && this._heightReactionDisposer(); this._searchReactionDisposer && this._searchReactionDisposer(); } @@ -610,7 +736,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe @action tryUpdateHeight() { if (this.props.Document.autoHeight && this._ref.current!.scrollHeight !== 0) { - console.log("DT = " + this.props.Document.title + " " + this._ref.current!.clientHeight + " " + this._ref.current!.scrollHeight + " " + this._ref.current!.textContent) + console.log("DT = " + this.props.Document.title + " " + this._ref.current!.clientHeight + " " + this._ref.current!.scrollHeight + " " + this._ref.current!.textContent); let xf = this._ref.current!.getBoundingClientRect(); let scrBounds = this.props.ScreenToLocalTransform().transformBounds(0, 0, xf.width, this._ref.current!.textContent === "" ? 35 : this._ref.current!.scrollHeight); let nh = this.props.Document.isTemplate ? 0 : NumCast(this.dataDoc.nativeHeight, 0); @@ -639,6 +765,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe // }); // ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: subitems, icon: "text-height" }); } + + render() { let self = this; let style = this.props.isOverlay ? "scroll" : "hidden"; diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts index 89799b2af..cae5623e6 100644 --- a/src/new_fields/RichTextField.ts +++ b/src/new_fields/RichTextField.ts @@ -4,6 +4,11 @@ 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"); +const delimiter = "\n"; +const joiner = ""; + @scriptingGlobal @Deserializable("RichTextField") export class RichTextField extends ObjectField { @@ -22,4 +27,43 @@ export class RichTextField extends ObjectField { [ToScriptString]() { return `new RichTextField("${this.Data}")`; } + + [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"); + + // 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 + let textParagraphs: string[] = paragraphs.map(concatenateParagraph); + let plainText = textParagraphs.join(joiner); + return plainText.substring(0, plainText.length - 1); + } + + [FromPlainText](plainText: string) { + // 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" }; + text.length && (paragraph.content = [{ type: "text", marks: [], text }]); // An empty paragraph gets treated as a line break + return paragraph; + }); + + // 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); + } + }
\ No newline at end of file diff --git a/src/server/Message.ts b/src/server/Message.ts index aaee143e8..4ec390ade 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -1,4 +1,5 @@ import { Utils } from "../Utils"; +import { google, docs_v1 } from "googleapis"; export class Message<T> { private _name: string; diff --git a/src/server/RouteStore.ts b/src/server/RouteStore.ts index e30015e39..5d977006a 100644 --- a/src/server/RouteStore.ts +++ b/src/server/RouteStore.ts @@ -30,6 +30,7 @@ export enum RouteStore { reset = "/reset/:token", // APIS - cognitiveServices = "/cognitiveservices" + cognitiveServices = "/cognitiveservices", + googleDocs = "/googleDocs/" }
\ No newline at end of file diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts new file mode 100644 index 000000000..817b2b696 --- /dev/null +++ b/src/server/apis/google/GoogleApiServerUtils.ts @@ -0,0 +1,109 @@ +import { google, docs_v1 } from "googleapis"; +import { createInterface } from "readline"; +import { readFile, writeFile } from "fs"; +import { OAuth2Client } from "google-auth-library"; + +/** + * Server side authentication for Google Api queries. + */ +export namespace GoogleApiServerUtils { + + // If modifying these scopes, delete token.json. + const prefix = 'https://www.googleapis.com/auth/'; + const SCOPES = [ + 'documents.readonly', + 'documents', + 'drive', + 'drive.file', + ]; + // The file token.json stores the user's access and refresh tokens, and is + // created automatically when the authorization flow completes for the first + // time. + export const parseBuffer = (data: Buffer) => JSON.parse(data.toString()); + + export namespace Docs { + + export interface CredentialPaths { + credentials: string; + token: string; + } + + export type Endpoint = docs_v1.Docs; + + export const GetEndpoint = async (paths: CredentialPaths) => { + return new Promise<Endpoint>((resolve, reject) => { + readFile(paths.credentials, (err, credentials) => { + if (err) { + reject(err); + return console.log('Error loading client secret file:', err); + } + return authorize(parseBuffer(credentials), paths.token).then(auth => { + resolve(google.docs({ version: "v1", auth })); + }); + }); + }); + }; + + } + + /** + * Create an OAuth2 client with the given credentials, and returns the promise resolving to the authenticated client + * @param {Object} credentials The authorization client credentials. + */ + export function authorize(credentials: any, token_path: string): Promise<OAuth2Client> { + const { client_secret, client_id, redirect_uris } = credentials.installed; + const oAuth2Client = new google.auth.OAuth2( + client_id, client_secret, redirect_uris[0]); + + return new Promise<OAuth2Client>((resolve, reject) => { + readFile(token_path, (err, token) => { + // Check if we have previously stored a token. + if (err) { + return getNewToken(oAuth2Client, token_path).then(resolve, reject); + } + oAuth2Client.setCredentials(parseBuffer(token)); + resolve(oAuth2Client); + }); + }); + } + + /** + * Get and store new token after prompting for user authorization, and then + * execute the given callback with the authorized OAuth2 client. + * @param {google.auth.OAuth2} oAuth2Client The OAuth2 client to get token for. + * @param {getEventsCallback} callback The callback for the authorized client. + */ + function getNewToken(oAuth2Client: OAuth2Client, token_path: string) { + return new Promise<OAuth2Client>((resolve, reject) => { + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: 'offline', + scope: SCOPES.map(relative => prefix + relative), + }); + console.log('Authorize this app by visiting this url:', authUrl); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question('Enter the code from that page here: ', (code) => { + rl.close(); + oAuth2Client.getToken(code, (err, token) => { + if (err || !token) { + reject(err); + return console.error('Error retrieving access token', err); + } + oAuth2Client.setCredentials(token); + // Store the token to disk for later program executions + writeFile(token_path, JSON.stringify(token), (err) => { + if (err) { + console.error(err); + reject(err); + } + console.log('Token stored to', token_path); + }); + resolve(oAuth2Client); + }); + }); + }); + } + +}
\ No newline at end of file diff --git a/src/server/youtubeApi/youtubeApiSample.d.ts b/src/server/apis/youtube/youtubeApiSample.d.ts index 427f54608..427f54608 100644 --- a/src/server/youtubeApi/youtubeApiSample.d.ts +++ b/src/server/apis/youtube/youtubeApiSample.d.ts diff --git a/src/server/youtubeApi/youtubeApiSample.js b/src/server/apis/youtube/youtubeApiSample.js index 50b3c7b38..50b3c7b38 100644 --- a/src/server/youtubeApi/youtubeApiSample.js +++ b/src/server/apis/youtube/youtubeApiSample.js diff --git a/src/server/credentials/google_docs_credentials.json b/src/server/credentials/google_docs_credentials.json new file mode 100644 index 000000000..8d097d363 --- /dev/null +++ b/src/server/credentials/google_docs_credentials.json @@ -0,0 +1 @@ +{"installed":{"client_id":"343179513178-ud6tvmh275r2fq93u9eesrnc66t6akh9.apps.googleusercontent.com","project_id":"quickstart-1565056383187","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"w8KIFSc0MQpmUYHed4qEzn8b","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"]}}
\ No newline at end of file diff --git a/src/server/credentials/google_docs_token.json b/src/server/credentials/google_docs_token.json new file mode 100644 index 000000000..07c02d56c --- /dev/null +++ b/src/server/credentials/google_docs_token.json @@ -0,0 +1 @@ +{"access_token":"ya29.GltjB4-x03xFpd2NY2555cxg1xlT_ajqRi78M9osOfdOF2jTIjlPkn_UZL8cUwVP0DPC8rH3vhhg8RpspFe8Vewx92shAO3RPos_uMH0CUqEiCiZlaaB5I3Jq3Mv","refresh_token":"1/teUKUqGKMLjVqs-eed0L8omI02pzSxMUYaxGc2QxBw0","scope":"https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents.readonly","token_type":"Bearer","expiry_date":1565654175862}
\ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index eae018f13..ef1829f30 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,7 +14,6 @@ import * as mobileDetect from 'mobile-detect'; import * as passport from 'passport'; import * as path from 'path'; import * as request from 'request'; -import * as rp from 'request-promise'; import * as io from 'socket.io'; import { Socket } from 'socket.io'; import * as webpack from 'webpack'; @@ -36,19 +35,19 @@ const port = 1050; // default port to listen const serverPort = 4321; import expressFlash = require('express-flash'); import flash = require('connect-flash'); -import c = require("crypto"); import { Search } from './Search'; -import { debug } from 'util'; import _ = require('lodash'); import * as Archiver from 'archiver'; -import * as AdmZip from 'adm-zip'; -import * as YoutubeApi from './youtubeApi/youtubeApiSample.js'; +import AdmZip from 'adm-zip'; +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"); -var SolrNode = require('solr-node'); -var shell = require('shelljs'); const download = (url: string, dest: fs.PathLike) => request.get(url).pipe(fs.createWriteStream(dest)); let youtubeApiKey: string; @@ -174,6 +173,13 @@ const read_text_file = (relativePath: string) => { }); }; +const write_text_file = (relativePath: string, contents: any) => { + let target = path.join(__dirname, relativePath); + return new Promise<void>((resolve, reject) => { + fs.writeFile(target, contents, (err) => err ? reject(err) : resolve()); + }); +}; + app.get("/version", (req, res) => { exec('"C:\\Program Files\\Git\\bin\\git.exe" rev-parse HEAD', (err, stdout, stderr) => { if (err) { @@ -790,6 +796,34 @@ 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<GaxiosResponse>; +type ApiHandler = (endpoint: docs_v1.Resource$Documents, parameters: any) => ApiResponse; +type Action = "create" | "retrieve" | "update"; + +const EndpointHandlerMap = new Map<Action, ApiHandler>([ + ["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 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; + } + res.send(undefined); + }); +}); + const suffixMap: { [type: string]: (string | [string, string | ((json: any) => any)]) } = { "number": "_n", "string": "_t", |