aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/client/apis/google_docs/GoogleApiClientUtils.ts224
-rw-r--r--src/client/views/DocumentDecorations.scss6
-rw-r--r--src/client/views/DocumentDecorations.tsx136
-rw-r--r--src/client/views/MainView.tsx6
-rw-r--r--src/client/views/collections/CollectionSubView.tsx14
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx148
-rw-r--r--src/new_fields/RichTextField.ts44
-rw-r--r--src/server/Message.ts1
-rw-r--r--src/server/RouteStore.ts3
-rw-r--r--src/server/apis/google/GoogleApiServerUtils.ts109
-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.json1
-rw-r--r--src/server/credentials/google_docs_token.json1
-rw-r--r--src/server/index.ts48
15 files changed, 713 insertions, 28 deletions
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",