aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBob Zeleznik <zzzman@gmail.com>2019-09-29 22:51:21 -0400
committerBob Zeleznik <zzzman@gmail.com>2019-09-29 22:51:21 -0400
commit4643ee692e8d1913918a104e576eb71fb20f7130 (patch)
tree6171fcad1f78844cf97f4ed4da7f1472022a49f4 /src
parent0ae9a7f6acbdf6ecade8d349981e8d6badef7ff9 (diff)
parent70a46647b00849ece22a172aeaa886eb02e94706 (diff)
fixed pdf uploading. merged.
Diffstat (limited to 'src')
-rw-r--r--src/Utils.ts90
-rw-r--r--src/client/Network.ts33
-rw-r--r--src/client/apis/google_docs/GoogleApiClientUtils.ts271
-rw-r--r--src/client/apis/google_docs/GooglePhotosClientUtils.ts352
-rw-r--r--src/client/documents/Documents.ts20
-rw-r--r--src/client/util/DictationManager.ts46
-rw-r--r--src/client/util/History.ts6
-rw-r--r--src/client/util/Import & Export/DirectoryImportBox.scss6
-rw-r--r--src/client/util/Import & Export/DirectoryImportBox.tsx156
-rw-r--r--src/client/util/ProsemirrorExampleTransfer.ts2
-rw-r--r--src/client/util/RichTextSchema.tsx10
-rw-r--r--src/client/util/SharingManager.scss136
-rw-r--r--src/client/util/SharingManager.tsx293
-rw-r--r--src/client/views/DocumentButtonBar.tsx1
-rw-r--r--src/client/views/DocumentDecorations.scss31
-rw-r--r--src/client/views/DocumentDecorations.tsx3
-rw-r--r--src/client/views/GlobalKeyHandler.ts2
-rw-r--r--src/client/views/InkingCanvas.scss2
-rw-r--r--src/client/views/Main.scss40
-rw-r--r--src/client/views/Main.tsx19
-rw-r--r--src/client/views/MainView.tsx273
-rw-r--r--src/client/views/MainViewModal.scss25
-rw-r--r--src/client/views/MainViewModal.tsx44
-rw-r--r--src/client/views/OverlayView.tsx3
-rw-r--r--src/client/views/ScriptingRepl.scss1
-rw-r--r--src/client/views/collections/CollectionBaseView.scss18
-rw-r--r--src/client/views/collections/CollectionBaseView.tsx19
-rw-r--r--src/client/views/collections/CollectionDockingView.tsx33
-rw-r--r--src/client/views/collections/CollectionSubView.tsx18
-rw-r--r--src/client/views/collections/CollectionView.tsx5
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx12
-rw-r--r--src/client/views/nodes/CollectionFreeFormDocumentView.scss8
-rw-r--r--src/client/views/nodes/DocumentView.scss15
-rw-r--r--src/client/views/nodes/DocumentView.tsx125
-rw-r--r--src/client/views/nodes/FormattedTextBox.tsx96
-rw-r--r--src/client/views/nodes/ImageBox.scss121
-rw-r--r--src/client/views/nodes/ImageBox.tsx31
-rw-r--r--src/client/views/pdf/Page.scss36
-rw-r--r--src/client/views/pdf/Page.tsx293
-rw-r--r--src/client/views/presentationview/PresentationList.tsx4
-rw-r--r--src/client/views/presentationview/PresentationView.tsx993
-rw-r--r--src/extensions/ArrayExtensions.ts13
-rw-r--r--src/extensions/General/Extensions.ts9
-rw-r--r--src/extensions/General/ExtensionsTypings.ts8
-rw-r--r--src/extensions/StringExtensions.ts17
-rw-r--r--src/new_fields/RichTextField.ts49
-rw-r--r--src/new_fields/RichTextUtils.ts519
-rw-r--r--src/server/DashUploadUtils.ts171
-rw-r--r--src/server/Message.ts1
-rw-r--r--src/server/RouteStore.ts6
-rw-r--r--src/server/apis/google/GoogleApiServerUtils.ts145
-rw-r--r--src/server/apis/google/GooglePhotosUploadUtils.ts165
-rw-r--r--src/server/apis/google/SharedTypes.ts21
-rw-r--r--src/server/authentication/config/passport.ts3
-rw-r--r--src/server/authentication/models/current_user_utils.ts6
-rw-r--r--src/server/credentials/google_docs_credentials.json12
-rw-r--r--src/server/credentials/google_docs_token.json1
-rw-r--r--src/server/database.ts430
-rw-r--r--src/server/index.ts294
-rw-r--r--src/server/updateSearch.ts123
-rw-r--r--src/typings/index.d.ts632
61 files changed, 4782 insertions, 1535 deletions
diff --git a/src/Utils.ts b/src/Utils.ts
index 2b00a6530..4b892aa70 100644
--- a/src/Utils.ts
+++ b/src/Utils.ts
@@ -3,21 +3,20 @@ import v5 = require("uuid/v5");
import { Socket } from 'socket.io';
import { Message } from './server/Message';
import { RouteStore } from './server/RouteStore';
-import requestPromise = require('request-promise');
-export class Utils {
+export namespace Utils {
- public static DRAG_THRESHOLD = 4;
+ export const DRAG_THRESHOLD = 4;
- public static GenerateGuid(): string {
+ export function GenerateGuid(): string {
return v4();
}
- public static GenerateDeterministicGuid(seed: string): string {
+ export function GenerateDeterministicGuid(seed: string): string {
return v5(seed, v5.URL);
}
- public static GetScreenTransform(ele?: HTMLElement): { scale: number, translateX: number, translateY: number } {
+ export function GetScreenTransform(ele?: HTMLElement): { scale: number, translateX: number, translateY: number } {
if (!ele) {
return { scale: 1, translateX: 1, translateY: 1 };
}
@@ -34,15 +33,23 @@ export class Utils {
* requested extension
* @param extension the specified sub-path to append to the window origin
*/
- public static prepend(extension: string): string {
+ export function prepend(extension: string): string {
return window.location.origin + extension;
}
- public static CorsProxy(url: string): string {
- return this.prepend(RouteStore.corsProxy + "/") + encodeURIComponent(url);
+ export function fileUrl(filename: string): string {
+ return prepend(`/files/${filename}`);
}
- public static CopyText(text: string) {
+ export function shareUrl(documentId: string): string {
+ return prepend(`/doc/${documentId}?sharing=true`);
+ }
+
+ export function CorsProxy(url: string): string {
+ return prepend(RouteStore.corsProxy + "/") + encodeURIComponent(url);
+ }
+
+ export function CopyText(text: string) {
var textArea = document.createElement("textarea");
textArea.value = text;
document.body.appendChild(textArea);
@@ -54,7 +61,7 @@ export class Utils {
document.body.removeChild(textArea);
}
- public static fromRGBAstr(rgba: string) {
+ export function fromRGBAstr(rgba: string) {
let rm = rgba.match(/rgb[a]?\(([0-9]+)/);
let r = rm ? Number(rm[1]) : 0;
let gm = rgba.match(/rgb[a]?\([0-9]+,([0-9]+)/);
@@ -65,11 +72,12 @@ export class Utils {
let a = am ? Number(am[1]) : 0;
return { r: r, g: g, b: b, a: a };
}
- public static toRGBAstr(col: { r: number, g: number, b: number, a?: number }) {
+
+ export function toRGBAstr(col: { r: number, g: number, b: number, a?: number }) {
return "rgba(" + col.r + "," + col.g + "," + col.b + (col.a !== undefined ? "," + col.a : "") + ")";
}
- public static HSLtoRGB(h: number, s: number, l: number) {
+ export function HSLtoRGB(h: number, s: number, l: number) {
// Must be fractions of 1
// s /= 100;
// l /= 100;
@@ -99,7 +107,7 @@ export class Utils {
return { r: r, g: g, b: b };
}
- public static RGBToHSL(r: number, g: number, b: number) {
+ export function RGBToHSL(r: number, g: number, b: number) {
// Make r, g, and b fractions of 1
r /= 255;
g /= 255;
@@ -141,7 +149,7 @@ export class Utils {
}
- public static GetClipboardText(): string {
+ export function GetClipboardText(): string {
var textArea = document.createElement("textarea");
document.body.appendChild(textArea);
textArea.focus();
@@ -154,51 +162,53 @@ export class Utils {
return val;
}
- public static loggingEnabled: Boolean = false;
- public static logFilter: number | undefined = undefined;
- private static log(prefix: string, messageName: string, message: any, receiving: boolean) {
- if (!this.loggingEnabled) {
+ export const loggingEnabled: Boolean = false;
+ export const logFilter: number | undefined = undefined;
+
+ function log(prefix: string, messageName: string, message: any, receiving: boolean) {
+ if (!loggingEnabled) {
return;
}
message = message || {};
- if (this.logFilter !== undefined && this.logFilter !== message.type) {
+ if (logFilter !== undefined && logFilter !== message.type) {
return;
}
let idString = (message.id || "").padStart(36, ' ');
prefix = prefix.padEnd(16, ' ');
console.log(`${prefix}: ${idString}, ${receiving ? 'receiving' : 'sending'} ${messageName} with data ${JSON.stringify(message)}`);
}
- private static loggingCallback(prefix: string, func: (args: any) => any, messageName: string) {
+
+ function loggingCallback(prefix: string, func: (args: any) => any, messageName: string) {
return (args: any) => {
- this.log(prefix, messageName, args, true);
+ log(prefix, messageName, args, true);
func(args);
};
}
- public static Emit<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T) {
- this.log("Emit", message.Name, args, false);
+ export function Emit<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T) {
+ log("Emit", message.Name, args, false);
socket.emit(message.Message, args);
}
- public static EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T): Promise<any>;
- public static EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T, fn: (args: any) => any): void;
- public static EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T, fn?: (args: any) => any): void | Promise<any> {
- this.log("Emit", message.Name, args, false);
+ export function EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T): Promise<any>;
+ export function EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T, fn: (args: any) => any): void;
+ export function EmitCallback<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, args: T, fn?: (args: any) => any): void | Promise<any> {
+ log("Emit", message.Name, args, false);
if (fn) {
- socket.emit(message.Message, args, this.loggingCallback('Receiving', fn, message.Name));
+ socket.emit(message.Message, args, loggingCallback('Receiving', fn, message.Name));
} else {
- return new Promise<any>(res => socket.emit(message.Message, args, this.loggingCallback('Receiving', res, message.Name)));
+ return new Promise<any>(res => socket.emit(message.Message, args, loggingCallback('Receiving', res, message.Name)));
}
}
- public static AddServerHandler<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, handler: (args: T) => any) {
- socket.on(message.Message, this.loggingCallback('Incoming', handler, message.Name));
+ export function AddServerHandler<T>(socket: Socket | SocketIOClient.Socket, message: Message<T>, handler: (args: T) => any) {
+ socket.on(message.Message, loggingCallback('Incoming', handler, message.Name));
}
- public static AddServerHandlerCallback<T>(socket: Socket, message: Message<T>, handler: (args: [T, (res: any) => any]) => any) {
+ export function AddServerHandlerCallback<T>(socket: Socket, message: Message<T>, handler: (args: [T, (res: any) => any]) => any) {
socket.on(message.Message, (arg: T, fn: (res: any) => any) => {
- this.log('S receiving', message.Name, arg, true);
- handler([arg, this.loggingCallback('S sending', fn, message.Name)]);
+ log('S receiving', message.Name, arg, true);
+ handler([arg, loggingCallback('S sending', fn, message.Name)]);
});
}
}
@@ -299,16 +309,6 @@ export namespace JSONUtils {
}
-export function PostToServer(relativeRoute: string, body: any) {
- let options = {
- method: "POST",
- uri: Utils.prepend(relativeRoute),
- json: true,
- body: body
- };
- return requestPromise.post(options);
-}
-
const easeInOutQuad = (currentTime: number, start: number, change: number, duration: number) => {
let newCurrentTime = currentTime / (duration / 2);
diff --git a/src/client/Network.ts b/src/client/Network.ts
new file mode 100644
index 000000000..75ccb5e99
--- /dev/null
+++ b/src/client/Network.ts
@@ -0,0 +1,33 @@
+import { Utils } from "../Utils";
+import { CurrentUserUtils } from "../server/authentication/models/current_user_utils";
+import requestPromise = require('request-promise');
+
+export namespace Identified {
+
+ export async function FetchFromServer(relativeRoute: string) {
+ return (await fetch(relativeRoute, { headers: { userId: CurrentUserUtils.id } })).text();
+ }
+
+ export async function PostToServer(relativeRoute: string, body?: any) {
+ let options = {
+ uri: Utils.prepend(relativeRoute),
+ method: "POST",
+ headers: { userId: CurrentUserUtils.id },
+ body,
+ json: true
+ };
+ return requestPromise.post(options);
+ }
+
+ export async function PostFormDataToServer(relativeRoute: string, formData: FormData) {
+ const parameters = {
+ method: 'POST',
+ headers: { userId: CurrentUserUtils.id },
+ body: formData,
+ };
+ const response = await fetch(relativeRoute, parameters);
+ const text = await response.json();
+ return text;
+ }
+
+} \ No newline at end of file
diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts
index 798886def..0f0f81891 100644
--- a/src/client/apis/google_docs/GoogleApiClientUtils.ts
+++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts
@@ -1,113 +1,136 @@
import { docs_v1, slides_v1 } from "googleapis";
-import { PostToServer } from "../../../Utils";
import { RouteStore } from "../../../server/RouteStore";
import { Opt } from "../../../new_fields/Doc";
import { isArray } from "util";
+import { EditorState } from "prosemirror-state";
+import { PostToServer } from "../../Network";
export const Pulls = "googleDocsPullCount";
export const Pushes = "googleDocsPushCount";
export namespace GoogleApiClientUtils {
- export enum Service {
- Documents = "Documents",
- Slides = "Slides"
- }
-
export enum Actions {
Create = "create",
Retrieve = "retrieve",
Update = "update"
}
- export enum WriteMode {
- Insert,
- Replace
- }
-
- export type Identifier = string;
- export type Reference = Identifier | CreateOptions;
- export type TextContent = string | string[];
- export type IdHandler = (id: Identifier) => any;
- export type CreationResult = Opt<Identifier>;
- export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>;
- export type ReadResult = { title?: string, body?: string };
+ export namespace Docs {
- export interface CreateOptions {
- service: Service;
- title?: string; // if excluded, will use a default title annotated with the current date
- }
+ export type RetrievalResult = Opt<docs_v1.Schema$Document>;
+ export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>;
- export interface RetrieveOptions {
- service: Service;
- identifier: Identifier;
- }
+ export interface UpdateOptions {
+ documentId: DocumentId;
+ requests: docs_v1.Schema$Request[];
+ }
- export interface ReadOptions {
- identifier: Identifier;
- removeNewlines?: boolean;
- }
+ export enum WriteMode {
+ Insert,
+ Replace
+ }
- export interface WriteOptions {
- mode: WriteMode;
- content: TextContent;
- reference: Reference;
- index?: number; // if excluded, will compute the last index of the document and append the content there
- }
+ export type DocumentId = string;
+ export type Reference = DocumentId | CreateOptions;
+ export interface Content {
+ text: string | string[];
+ requests: docs_v1.Schema$Request[];
+ }
+ export type IdHandler = (id: DocumentId) => any;
+ export type CreationResult = Opt<DocumentId>;
+ export type ReadLinesResult = Opt<{ title?: string, bodyLines?: string[] }>;
+ export type ReadResult = { title: string, body: string };
+ export interface ImportResult {
+ title: string;
+ text: string;
+ state: EditorState;
+ }
- /**
- * After following the authentication routine, which connects this API call to the current signed in account
- * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which
- * should appear in the user's Google Doc library instantaneously.
- *
- * @param options the title to assign to the new document, and the information necessary
- * to store the new documentId returned from the creation process
- * @returns the documentId of the newly generated document, or undefined if the creation process fails.
- */
- export const create = async (options: CreateOptions): Promise<CreationResult> => {
- const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Create}`;
- const parameters = {
- requestBody: {
- title: options.title || `Dash Export (${new Date().toDateString()})`
- }
- };
- try {
- const schema: any = await PostToServer(path, parameters);
- let key = ["document", "presentation"].find(prefix => `${prefix}Id` in schema) + "Id";
- return schema[key];
- } catch {
- return undefined;
+ export interface CreateOptions {
+ title?: string; // if excluded, will use a default title annotated with the current date
}
- };
- export namespace Docs {
+ export interface RetrieveOptions {
+ documentId: DocumentId;
+ }
- export type RetrievalResult = Opt<docs_v1.Schema$Document | slides_v1.Schema$Presentation>;
- export type UpdateResult = Opt<docs_v1.Schema$BatchUpdateDocumentResponse>;
+ export interface ReadOptions {
+ documentId: DocumentId;
+ removeNewlines?: boolean;
+ }
- export interface UpdateOptions {
- documentId: Identifier;
- requests: docs_v1.Schema$Request[];
+ export interface WriteOptions {
+ mode: WriteMode;
+ content: Content;
+ reference: Reference;
+ index?: number; // if excluded, will compute the last index of the document and append the content there
}
+ /**
+ * After following the authentication routine, which connects this API call to the current signed in account
+ * and grants the appropriate permissions, this function programmatically creates an arbitrary Google Doc which
+ * should appear in the user's Google Doc library instantaneously.
+ *
+ * @param options the title to assign to the new document, and the information necessary
+ * to store the new documentId returned from the creation process
+ * @returns the documentId of the newly generated document, or undefined if the creation process fails.
+ */
+ export const create = async (options: CreateOptions): Promise<CreationResult> => {
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Create}`;
+ const parameters = {
+ requestBody: {
+ title: options.title || `Dash Export (${new Date().toDateString()})`
+ }
+ };
+ try {
+ const schema: docs_v1.Schema$Document = await PostToServer(path, parameters);
+ return schema.documentId;
+ } catch {
+ return undefined;
+ }
+ };
+
export namespace Utils {
- export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): string => {
- const fragments: string[] = [];
+ export type ExtractResult = { text: string, paragraphs: DeconstructedParagraph[] };
+ export const extractText = (document: docs_v1.Schema$Document, removeNewlines = false): ExtractResult => {
+ let paragraphs = extractParagraphs(document);
+ let text = paragraphs.map(paragraph => paragraph.contents.filter(content => !("inlineObjectId" in content)).map(run => run as docs_v1.Schema$TextRun).join("")).join("");
+ text = text.substring(0, text.length - 1);
+ removeNewlines && text.ReplaceAll("\n", "");
+ return { text, paragraphs };
+ };
+
+ export type ContentArray = (docs_v1.Schema$TextRun | docs_v1.Schema$InlineObjectElement)[];
+ export type DeconstructedParagraph = { contents: ContentArray, bullet: Opt<number> };
+ const extractParagraphs = (document: docs_v1.Schema$Document, filterEmpty = true): DeconstructedParagraph[] => {
+ const fragments: DeconstructedParagraph[] = [];
if (document.body && document.body.content) {
for (const element of document.body.content) {
- if (element.paragraph && element.paragraph.elements) {
- for (const inner of element.paragraph.elements) {
- if (inner && inner.textRun) {
- const fragment = inner.textRun.content;
- fragment && fragments.push(fragment);
+ let runs: ContentArray = [];
+ let bullet: Opt<number>;
+ if (element.paragraph) {
+ if (element.paragraph.elements) {
+ for (const inner of element.paragraph.elements) {
+ if (inner) {
+ if (inner.textRun) {
+ let run = inner.textRun;
+ (run.content || !filterEmpty) && runs.push(inner.textRun);
+ } else if (inner.inlineObjectElement) {
+ runs.push(inner.inlineObjectElement);
+ }
+ }
}
}
+ if (element.paragraph.bullet) {
+ bullet = element.paragraph.bullet.nestingLevel || 0;
+ }
}
+ (runs.length || !filterEmpty) && fragments.push({ contents: runs, bullet });
}
}
- const text = fragments.join("");
- return removeNewlines ? text.ReplaceAll("\n", "") : text;
+ return fragments;
};
export const endOf = (schema: docs_v1.Schema$Document): number | undefined => {
@@ -130,27 +153,19 @@ export namespace GoogleApiClientUtils {
}
- const KeyMapping = new Map<Service, string>([
- [Service.Documents, "documentId"],
- [Service.Slides, "presentationId"]
- ]);
-
export const retrieve = async (options: RetrieveOptions): Promise<RetrievalResult> => {
- const path = `${RouteStore.googleDocs}/${options.service}/${Actions.Retrieve}`;
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Retrieve}`;
try {
- let parameters: any = {}, key: string | undefined;
- if ((key = KeyMapping.get(options.service))) {
- parameters[key] = options.identifier;
- const schema: RetrievalResult = await PostToServer(path, parameters);
- return schema;
- }
+ const parameters = { documentId: options.documentId };
+ const schema: RetrievalResult = await PostToServer(path, parameters);
+ return schema;
} catch {
return undefined;
}
};
export const update = async (options: UpdateOptions): Promise<UpdateResult> => {
- const path = `${RouteStore.googleDocs}/${Service.Documents}/${Actions.Update}`;
+ const path = `${RouteStore.googleDocs}/Documents/${Actions.Update}`;
const parameters = {
documentId: options.documentId,
requestBody: {
@@ -165,41 +180,49 @@ export namespace GoogleApiClientUtils {
}
};
- export const read = async (options: ReadOptions): Promise<ReadResult> => {
- return retrieve({ ...options, service: Service.Documents }).then(document => {
- let result: ReadResult = {};
+ export const read = async (options: ReadOptions): Promise<Opt<ReadResult>> => {
+ return retrieve({ documentId: options.documentId }).then(document => {
if (document) {
- let title = document.title;
- let body = Utils.extractText(document, options.removeNewlines);
- result = { title, body };
+ let title = document.title!;
+ let body = Utils.extractText(document, options.removeNewlines).text;
+ return { title, body };
}
- return result;
});
};
- export const readLines = async (options: ReadOptions): Promise<ReadLinesResult> => {
- return retrieve({ ...options, service: Service.Documents }).then(document => {
- let result: ReadLinesResult = {};
+ export const readLines = async (options: ReadOptions): Promise<Opt<ReadLinesResult>> => {
+ return retrieve({ documentId: options.documentId }).then(document => {
if (document) {
let title = document.title;
- let bodyLines = Utils.extractText(document).split("\n");
+ let bodyLines = Utils.extractText(document).text.split("\n");
options.removeNewlines && (bodyLines = bodyLines.filter(line => line.length));
- result = { title, bodyLines };
+ return { title, bodyLines };
}
- return result;
});
};
+ export const setStyle = async (options: UpdateOptions) => {
+ let replies: any = await update({
+ documentId: options.documentId,
+ requests: options.requests
+ });
+ if ("errors" in replies) {
+ console.log("Write operation failed:");
+ console.log(replies.errors.map((error: any) => error.message));
+ }
+ return replies;
+ };
+
export const write = async (options: WriteOptions): Promise<UpdateResult> => {
const requests: docs_v1.Schema$Request[] = [];
- const identifier = await Utils.initialize(options.reference);
- if (!identifier) {
+ const documentId = await Utils.initialize(options.reference);
+ if (!documentId) {
return undefined;
}
let index = options.index;
const mode = options.mode;
if (!(index && mode === WriteMode.Insert)) {
- let schema = await retrieve({ identifier, service: Service.Documents });
+ let schema = await retrieve({ documentId });
if (!schema || !(index = Utils.endOf(schema))) {
return undefined;
}
@@ -215,7 +238,7 @@ export namespace GoogleApiClientUtils {
});
index = 1;
}
- const text = options.content;
+ const text = options.content.text;
text.length && requests.push({
insertText: {
text: isArray(text) ? text.join("\n") : text,
@@ -225,47 +248,15 @@ export namespace GoogleApiClientUtils {
if (!requests.length) {
return undefined;
}
- let replies: any = await update({ documentId: identifier, requests });
- let errors = "errors";
- if (errors in replies) {
+ requests.push(...options.content.requests);
+ let replies: any = await update({ documentId: documentId, requests });
+ if ("errors" in replies) {
console.log("Write operation failed:");
- console.log(replies[errors].map((error: any) => error.message));
+ console.log(replies.errors.map((error: any) => error.message));
}
return replies;
};
}
- export namespace Slides {
-
- export namespace Utils {
-
- export const extractTextBoxes = (slides: slides_v1.Schema$Page[]) => {
- slides.map(slide => {
- let elements = slide.pageElements;
- if (elements) {
- let textboxes: slides_v1.Schema$TextContent[] = [];
- for (let element of elements) {
- if (element && element.shape && element.shape.shapeType === "TEXT_BOX" && element.shape.text) {
- textboxes.push(element.shape.text);
- }
- }
- textboxes.map(text => {
- if (text.textElements) {
- text.textElements.map(element => {
-
- });
- }
- if (text.lists) {
-
- }
- });
- }
- });
- };
-
- }
-
- }
-
} \ No newline at end of file
diff --git a/src/client/apis/google_docs/GooglePhotosClientUtils.ts b/src/client/apis/google_docs/GooglePhotosClientUtils.ts
new file mode 100644
index 000000000..29cc042b6
--- /dev/null
+++ b/src/client/apis/google_docs/GooglePhotosClientUtils.ts
@@ -0,0 +1,352 @@
+import { Utils } from "../../../Utils";
+import { RouteStore } from "../../../server/RouteStore";
+import { ImageField } from "../../../new_fields/URLField";
+import { Cast, StrCast } from "../../../new_fields/Types";
+import { Doc, Opt, DocListCastAsync } from "../../../new_fields/Doc";
+import { Id } from "../../../new_fields/FieldSymbols";
+import Photos = require('googlephotos');
+import { RichTextField } from "../../../new_fields/RichTextField";
+import { RichTextUtils } from "../../../new_fields/RichTextUtils";
+import { EditorState } from "prosemirror-state";
+import { FormattedTextBox } from "../../views/nodes/FormattedTextBox";
+import { Docs, DocumentOptions } from "../../documents/Documents";
+import { NewMediaItemResult, MediaItem } from "../../../server/apis/google/SharedTypes";
+import { AssertionError } from "assert";
+import { DocumentView } from "../../views/nodes/DocumentView";
+import { DocumentManager } from "../../util/DocumentManager";
+import { Identified } from "../../Network";
+
+export namespace GooglePhotos {
+
+ const endpoint = async () => {
+ const accessToken = await Identified.FetchFromServer(RouteStore.googlePhotosAccessToken);
+ return new Photos(accessToken);
+ };
+
+ export enum MediaType {
+ ALL_MEDIA = 'ALL_MEDIA',
+ PHOTO = 'PHOTO',
+ VIDEO = 'VIDEO'
+ }
+
+ export type AlbumReference = { id: string } | { title: string };
+
+ export interface MediaInput {
+ url: string;
+ description: string;
+ }
+
+ export const ContentCategories = {
+ NONE: 'NONE',
+ LANDSCAPES: 'LANDSCAPES',
+ RECEIPTS: 'RECEIPTS',
+ CITYSCAPES: 'CITYSCAPES',
+ LANDMARKS: 'LANDMARKS',
+ SELFIES: 'SELFIES',
+ PEOPLE: 'PEOPLE',
+ PETS: 'PETS',
+ WEDDINGS: 'WEDDINGS',
+ BIRTHDAYS: 'BIRTHDAYS',
+ DOCUMENTS: 'DOCUMENTS',
+ TRAVEL: 'TRAVEL',
+ ANIMALS: 'ANIMALS',
+ FOOD: 'FOOD',
+ SPORT: 'SPORT',
+ NIGHT: 'NIGHT',
+ PERFORMANCES: 'PERFORMANCES',
+ WHITEBOARDS: 'WHITEBOARDS',
+ SCREENSHOTS: 'SCREENSHOTS',
+ UTILITY: 'UTILITY',
+ ARTS: 'ARTS',
+ CRAFTS: 'CRAFTS',
+ FASHION: 'FASHION',
+ HOUSES: 'HOUSES',
+ GARDENS: 'GARDENS',
+ FLOWERS: 'FLOWERS',
+ HOLIDAYS: 'HOLIDAYS'
+ };
+
+ export namespace Export {
+
+ export interface AlbumCreationResult {
+ albumId: string;
+ mediaItems: MediaItem[];
+ }
+
+ export interface AlbumCreationOptions {
+ collection: Doc;
+ title?: string;
+ descriptionKey?: string;
+ tag?: boolean;
+ }
+
+ export const CollectionToAlbum = async (options: AlbumCreationOptions): Promise<Opt<AlbumCreationResult>> => {
+ const { collection, title, descriptionKey, tag } = options;
+ const dataDocument = Doc.GetProto(collection);
+ const images = ((await DocListCastAsync(dataDocument.data)) || []).filter(doc => Cast(doc.data, ImageField));
+ if (!images || !images.length) {
+ return undefined;
+ }
+ const resolved = title ? title : (StrCast(collection.title) || `Dash Collection (${collection[Id]}`);
+ const { id, productUrl } = await Create.Album(resolved);
+ const newMediaItemResults = await Transactions.UploadImages(images, { id }, descriptionKey);
+ if (newMediaItemResults) {
+ const mediaItems = newMediaItemResults.map(item => item.mediaItem);
+ if (mediaItems.length !== images.length) {
+ throw new AssertionError({ actual: mediaItems.length, expected: images.length });
+ }
+ const idMapping = new Doc;
+ for (let i = 0; i < images.length; i++) {
+ const image = Doc.GetProto(images[i]);
+ const mediaItem = mediaItems[i];
+ image.googlePhotosId = mediaItem.id;
+ image.googlePhotosAlbumUrl = productUrl;
+ image.googlePhotosUrl = mediaItem.productUrl || mediaItem.baseUrl;
+ idMapping[mediaItem.id] = image;
+ }
+ collection.googlePhotosAlbumUrl = productUrl;
+ collection.googlePhotosIdMapping = idMapping;
+ if (tag) {
+ await Query.TagChildImages(collection);
+ }
+ collection.albumId = id;
+ Transactions.AddTextEnrichment(collection, `Find me at ${Utils.prepend(`/doc/${collection[Id]}?sharing=true`)}`);
+ return { albumId: id, mediaItems };
+ }
+ };
+
+ }
+
+ export namespace Import {
+
+ export type CollectionConstructor = (data: Array<Doc>, options: DocumentOptions, ...args: any) => Doc;
+
+ export const CollectionFromSearch = async (constructor: CollectionConstructor, requested: Opt<Partial<Query.SearchOptions>>): Promise<Doc> => {
+ let response = await Query.ContentSearch(requested);
+ let uploads = await Transactions.WriteMediaItemsToServer(response);
+ const children = uploads.map((upload: Transactions.UploadInformation) => {
+ let document = Docs.Create.ImageDocument(Utils.fileUrl(upload.fileNames.clean));
+ document.fillColumn = true;
+ document.contentSize = upload.contentSize;
+ return document;
+ });
+ const options = { width: 500, height: 500 };
+ return constructor(children, options);
+ };
+
+ }
+
+ export namespace Query {
+
+ const delimiter = ", ";
+ const comparator = (a: string, b: string) => (a < b) ? -1 : (a > b ? 1 : 0);
+
+ export const TagChildImages = async (collection: Doc) => {
+ const idMapping = await Cast(collection.googlePhotosIdMapping, Doc);
+ if (!idMapping) {
+ throw new Error("Appending image metadata requires that the targeted collection have already been mapped to an album!");
+ }
+ const tagMapping = new Map<string, string>();
+ const images = (await DocListCastAsync(collection.data))!.map(Doc.GetProto);
+ images && images.forEach(image => tagMapping.set(image[Id], ContentCategories.NONE));
+ const values = Object.values(ContentCategories);
+ for (let value of values) {
+ if (value !== ContentCategories.NONE) {
+ const results = await ContentSearch({ included: [value] });
+ if (results.mediaItems) {
+ const ids = results.mediaItems.map(item => item.id);
+ for (let id of ids) {
+ const image = await Cast(idMapping[id], Doc);
+ if (image) {
+ const key = image[Id];
+ const tags = tagMapping.get(key)!;
+ if (!tags.includes(value)) {
+ tagMapping.set(key, tags + delimiter + value);
+ }
+ }
+ }
+ }
+ }
+ }
+ images && images.forEach(image => {
+ const concatenated = tagMapping.get(image[Id])!;
+ const tags = concatenated.split(delimiter);
+ if (tags.length > 1) {
+ const cleaned = concatenated.replace(ContentCategories.NONE + delimiter, "");
+ image.googlePhotosTags = cleaned.split(delimiter).sort(comparator).join(delimiter);
+ } else {
+ image.googlePhotosTags = ContentCategories.NONE;
+ }
+ });
+
+ };
+
+ interface DateRange {
+ after: Date;
+ before: Date;
+ }
+
+ const DefaultSearchOptions: SearchOptions = {
+ pageSize: 50,
+ included: [],
+ excluded: [],
+ date: undefined,
+ includeArchivedMedia: true,
+ excludeNonAppCreatedData: false,
+ type: MediaType.ALL_MEDIA,
+ };
+
+ export interface SearchOptions {
+ pageSize: number;
+ included: string[];
+ excluded: string[];
+ date: Opt<Date | DateRange>;
+ includeArchivedMedia: boolean;
+ excludeNonAppCreatedData: boolean;
+ type: MediaType;
+ }
+
+ export interface SearchResponse {
+ mediaItems: any[];
+ nextPageToken: string;
+ }
+
+ export const AlbumSearch = async (albumId: string, pageSize = 100): Promise<MediaItem[]> => {
+ const photos = await endpoint();
+ let mediaItems: MediaItem[] = [];
+ let nextPageTokenStored: Opt<string> = undefined;
+ let found = 0;
+ do {
+ const response: any = await photos.mediaItems.search(albumId, pageSize, nextPageTokenStored);
+ mediaItems.push(...response.mediaItems);
+ nextPageTokenStored = response.nextPageToken;
+ } while (found);
+ return mediaItems;
+ };
+
+ export const ContentSearch = async (requested: Opt<Partial<SearchOptions>>): Promise<SearchResponse> => {
+ const options = requested || DefaultSearchOptions;
+ const photos = await endpoint();
+ const filters = new photos.Filters(options.includeArchivedMedia === undefined ? true : options.includeArchivedMedia);
+
+ const included = options.included || [];
+ const excluded = options.excluded || [];
+ const contentFilter = new photos.ContentFilter();
+ included.length && included.forEach(category => contentFilter.addIncludedContentCategories(category));
+ excluded.length && excluded.forEach(category => contentFilter.addExcludedContentCategories(category));
+ filters.setContentFilter(contentFilter);
+
+ const date = options.date;
+ if (date) {
+ const dateFilter = new photos.DateFilter();
+ if (date instanceof Date) {
+ dateFilter.addDate(date);
+ } else {
+ dateFilter.addRange(date.after, date.before);
+ }
+ filters.setDateFilter(dateFilter);
+ }
+
+ filters.setMediaTypeFilter(new photos.MediaTypeFilter(options.type || MediaType.ALL_MEDIA));
+
+ return new Promise<SearchResponse>(resolve => {
+ photos.mediaItems.search(filters, options.pageSize || 100).then(resolve);
+ });
+ };
+
+ export const GetImage = async (mediaItemId: string): Promise<Transactions.MediaItem> => {
+ return (await endpoint()).mediaItems.get(mediaItemId);
+ };
+
+ }
+
+ namespace Create {
+
+ export const Album = async (title: string) => {
+ return (await endpoint()).albums.create(title);
+ };
+
+ }
+
+ export namespace Transactions {
+
+ export interface UploadInformation {
+ mediaPaths: string[];
+ fileNames: { [key: string]: string };
+ contentSize?: number;
+ contentType?: string;
+ }
+
+ export interface MediaItem {
+ id: string;
+ filename: string;
+ baseUrl: string;
+ }
+
+ export const ListAlbums = async () => (await endpoint()).albums.list();
+
+ export const AddTextEnrichment = async (collection: Doc, content?: string) => {
+ const photos = await endpoint();
+ const albumId = StrCast(collection.albumId);
+ if (albumId && albumId.length) {
+ const enrichment = new photos.TextEnrichment(content || Utils.prepend("/doc/" + collection[Id]));
+ const position = new photos.AlbumPosition(photos.AlbumPosition.POSITIONS.FIRST_IN_ALBUM);
+ const enrichmentItem = await photos.albums.addEnrichment(albumId, enrichment, position);
+ if (enrichmentItem) {
+ return enrichmentItem.id;
+ }
+ }
+ };
+
+ export const WriteMediaItemsToServer = async (body: { mediaItems: any[] }): Promise<UploadInformation[]> => {
+ const uploads = await Identified.PostToServer(RouteStore.googlePhotosMediaDownload, body);
+ return uploads;
+ };
+
+ export const UploadThenFetch = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption") => {
+ const newMediaItems = await UploadImages(sources, album, descriptionKey);
+ if (!newMediaItems) {
+ return undefined;
+ }
+ const baseUrls: string[] = await Promise.all(newMediaItems.map(item => {
+ return new Promise<string>(resolve => Query.GetImage(item.mediaItem.id).then(item => resolve(item.baseUrl)));
+ }));
+ return baseUrls;
+ };
+
+ export const UploadImages = async (sources: Doc[], album?: AlbumReference, descriptionKey = "caption"): Promise<Opt<NewMediaItemResult[]>> => {
+ if (album && "title" in album) {
+ album = await Create.Album(album.title);
+ }
+ const media: MediaInput[] = [];
+ for (let source of sources) {
+ const data = Cast(Doc.GetProto(source).data, ImageField);
+ if (!data) {
+ return;
+ }
+ const url = data.url.href;
+ const target = Doc.MakeAlias(source);
+ const description = parseDescription(target, descriptionKey);
+ await DocumentView.makeCustomViewClicked(target, undefined);
+ media.push({ url, description });
+ }
+ if (media.length) {
+ const uploads: NewMediaItemResult[] = await Identified.PostToServer(RouteStore.googlePhotosMediaUpload, { media, album });
+ return uploads;
+ }
+ };
+
+ const parseDescription = (document: Doc, descriptionKey: string) => {
+ let description: string = Utils.prepend(`/doc/${document[Id]}?sharing=true`);
+ const target = document[descriptionKey];
+ if (typeof target === "string") {
+ description = target;
+ } else if (target instanceof RichTextField) {
+ description = RichTextUtils.ToPlainText(EditorState.fromJSON(FormattedTextBox.Instance.config, JSON.parse(target.Data)));
+ }
+ return description;
+ };
+
+ }
+
+} \ No newline at end of file
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 392dca373..d1ec2ac39 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -20,8 +20,8 @@ import { AttributeTransformationModel } from "../northstar/core/attribute/Attrib
import { AggregateFunction } from "../northstar/model/idea/idea";
import { MINIMIZED_ICON_SIZE } from "../views/globalCssVariables.scss";
import { IconBox } from "../views/nodes/IconBox";
-import { Field, Doc, Opt, DocListCastAsync } from "../../new_fields/Doc";
import { OmitKeys, JSONUtils } from "../../Utils";
+import { Field, Doc, Opt, DocListCastAsync } from "../../new_fields/Doc";
import { ImageField, VideoField, AudioField, PdfField, WebField, YoutubeField } from "../../new_fields/URLField";
import { HtmlField } from "../../new_fields/HtmlField";
import { List } from "../../new_fields/List";
@@ -159,7 +159,6 @@ export namespace Docs {
[DocumentType.LINKDOC, {
data: new List<Doc>(),
layout: { view: EmptyBox },
- options: {}
}],
[DocumentType.YOUTUBE, {
layout: { view: YoutubeBox }
@@ -334,7 +333,13 @@ export namespace Docs {
export function ImageDocument(url: string, options: DocumentOptions = {}) {
let imgField = new ImageField(new URL(url));
let inst = InstanceFromProto(Prototypes.get(DocumentType.IMG), imgField, { title: path.basename(url), ...options });
- requestImageSize(imgField.url.href)
+ let target = imgField.url.href;
+ if (new RegExp(window.location.origin).test(target)) {
+ let extension = path.extname(target);
+ target = `${target.substring(0, target.length - extension.length)}_o${extension}`;
+ }
+ // if (target !== "http://www.cs.brown.edu/") {
+ requestImageSize(target)
.then((size: any) => {
let aspect = size.height / size.width;
if (!inst.proto!.nativeWidth) {
@@ -344,6 +349,7 @@ export namespace Docs {
inst.proto!.height = NumCast(inst.proto!.width) * aspect;
})
.catch((err: any) => console.log(err));
+ // }
return inst;
}
export function PresDocument(initial: List<Doc> = new List(), options: DocumentOptions = {}) {
@@ -508,10 +514,13 @@ export namespace Docs {
* @param title an optional title to give to the highest parent document in the hierarchy
*/
export function DocumentHierarchyFromJson(input: any, title?: string): Opt<Doc> {
- if (input === null || ![...primitives, "object"].includes(typeof input)) {
+ if (input === undefined || input === null || ![...primitives, "object"].includes(typeof input)) {
return undefined;
}
- let parsed: any = typeof input === "string" ? JSONUtils.tryParse(input) : input;
+ let parsed = input;
+ if (typeof input === "string") {
+ parsed = JSONUtils.tryParse(input);
+ }
let converted: Doc;
if (typeof parsed === "object" && !(parsed instanceof Array)) {
converted = convertObject(parsed, title);
@@ -664,7 +673,6 @@ export namespace DocUtils {
Doc.GetProto(source).links = ComputedField.MakeFunction("links(this)");
Doc.GetProto(target).links = ComputedField.MakeFunction("links(this)");
-
}, "make link");
return linkDocProto;
}
diff --git a/src/client/util/DictationManager.ts b/src/client/util/DictationManager.ts
index c4016d2a5..cebb56bbe 100644
--- a/src/client/util/DictationManager.ts
+++ b/src/client/util/DictationManager.ts
@@ -3,7 +3,7 @@ import { DocumentView } from "../views/nodes/DocumentView";
import { UndoManager } from "./UndoManager";
import * as interpreter from "words-to-numbers";
import { DocumentType } from "../documents/DocumentTypes";
-import { Doc } from "../../new_fields/Doc";
+import { Doc, Opt } from "../../new_fields/Doc";
import { List } from "../../new_fields/List";
import { Docs } from "../documents/Documents";
import { CollectionViewType } from "../views/collections/CollectionBaseView";
@@ -40,12 +40,26 @@ export namespace DictationManager {
webkitSpeechRecognition: any;
}
}
- const { webkitSpeechRecognition }: CORE.IWindow = window as CORE.IWindow;
+ const { webkitSpeechRecognition }: CORE.IWindow = window as any as CORE.IWindow;
export const placeholder = "Listening...";
export namespace Controls {
export const Infringed = "unable to process: dictation manager still involved in previous session";
+ const browser = (() => {
+ let identifier = navigator.userAgent.toLowerCase();
+ if (identifier.indexOf("safari") >= 0) {
+ return "Safari";
+ }
+ if (identifier.indexOf("chrome") >= 0) {
+ return "Chrome";
+ }
+ if (identifier.indexOf("firefox") >= 0) {
+ return "Firefox";
+ }
+ return "Unidentified Browser";
+ })();
+ const unsupported = `listening is not supported in ${browser}`;
const intraSession = ". ";
const interSession = " ... ";
@@ -55,8 +69,7 @@ export namespace DictationManager {
let current: string | undefined = undefined;
let sessionResults: string[] = [];
- const recognizer: SpeechRecognition = new webkitSpeechRecognition() || new SpeechRecognition();
- recognizer.onstart = () => console.log("initiating speech recognition session...");
+ const recognizer: Opt<SpeechRecognition> = webkitSpeechRecognition ? new webkitSpeechRecognition() : undefined;
export type InterimResultHandler = (results: string) => any;
export type ContinuityArgs = { indefinite: boolean } | false;
@@ -109,6 +122,10 @@ export namespace DictationManager {
};
const listenImpl = (options?: Partial<ListeningOptions>) => {
+ if (!recognizer) {
+ console.log(unsupported);
+ return unsupported;
+ }
if (isListening) {
return Infringed;
}
@@ -121,6 +138,7 @@ export namespace DictationManager {
let intra = options && options.delimiters ? options.delimiters.intra : undefined;
let inter = options && options.delimiters ? options.delimiters.inter : undefined;
+ recognizer.onstart = () => console.log("initiating speech recognition session...");
recognizer.interimResults = handler !== undefined;
recognizer.continuous = continuous === undefined ? false : continuous !== false;
recognizer.lang = language === undefined ? "en-US" : language;
@@ -167,14 +185,20 @@ export namespace DictationManager {
} else {
resolve(current);
}
- reset();
+ current = undefined;
+ sessionResults = [];
+ isListening = false;
+ isManuallyStopped = false;
+ recognizer.onresult = null;
+ recognizer.onerror = null;
+ recognizer.onend = null;
};
});
};
export const stop = (salvageSession = true) => {
- if (!isListening) {
+ if (!isListening || !recognizer) {
return;
}
isManuallyStopped = true;
@@ -197,16 +221,6 @@ export namespace DictationManager {
return transcripts.join(delimiter || intraSession);
};
- const reset = () => {
- current = undefined;
- sessionResults = [];
- isListening = false;
- isManuallyStopped = false;
- recognizer.onresult = null;
- recognizer.onerror = null;
- recognizer.onend = null;
- };
-
}
export namespace Commands {
diff --git a/src/client/util/History.ts b/src/client/util/History.ts
index 67c8e931d..899abbe40 100644
--- a/src/client/util/History.ts
+++ b/src/client/util/History.ts
@@ -16,8 +16,10 @@ export namespace HistoryUtil {
initializers?: {
[docId: string]: DocInitializerList;
};
+ safe?: boolean;
readonly?: boolean;
nro?: boolean;
+ sharing?: boolean;
}
export type ParsedUrl = DocUrl;
@@ -143,7 +145,7 @@ export namespace HistoryUtil {
};
}
- addParser("doc", {}, { readonly: true, initializers: true, nro: true }, (pathname, opts, current) => {
+ addParser("doc", {}, { readonly: true, initializers: true, nro: true, sharing: true }, (pathname, opts, current) => {
if (pathname.length !== 2) return undefined;
current.initializers = current.initializers || {};
@@ -158,7 +160,7 @@ export namespace HistoryUtil {
export function parseUrl(location: Location | URL): ParsedUrl | undefined {
const pathname = location.pathname.substring(1);
const search = location.search;
- const opts = qs.parse(search, { sort: false });
+ const opts = search.length ? qs.parse(search, { sort: false }) : {};
let pathnameSplit = pathname.split("/");
const type = pathnameSplit[0];
diff --git a/src/client/util/Import & Export/DirectoryImportBox.scss b/src/client/util/Import & Export/DirectoryImportBox.scss
new file mode 100644
index 000000000..d33cb524b
--- /dev/null
+++ b/src/client/util/Import & Export/DirectoryImportBox.scss
@@ -0,0 +1,6 @@
+.phase {
+ position: absolute;
+ top: 15px;
+ left: 15px;
+ font-style: italic;
+} \ No newline at end of file
diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx
index dc6a0cb7a..d3f81b992 100644
--- a/src/client/util/Import & Export/DirectoryImportBox.tsx
+++ b/src/client/util/Import & Export/DirectoryImportBox.tsx
@@ -1,9 +1,8 @@
import "fs";
import React = require("react");
-import { Doc, Opt, DocListCast, DocListCastAsync } from "../../../new_fields/Doc";
-import { DocServer } from "../../DocServer";
+import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc";
import { RouteStore } from "../../../server/RouteStore";
-import { action, observable, autorun, runInAction, computed } from "mobx";
+import { action, observable, autorun, runInAction, computed, reaction, IReactionDisposer } from "mobx";
import { FieldViewProps, FieldView } from "../../views/nodes/FieldView";
import Measure, { ContentRect } from "react-measure";
import { library } from '@fortawesome/fontawesome-svg-core';
@@ -18,20 +17,33 @@ import { Id } from "../../../new_fields/FieldSymbols";
import { List } from "../../../new_fields/List";
import { Cast, BoolCast, NumCast } from "../../../new_fields/Types";
import { listSpec } from "../../../new_fields/Schema";
+import { GooglePhotos } from "../../apis/google_docs/GooglePhotosClientUtils";
+import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField";
+import "./DirectoryImportBox.scss";
+import { Identified } from "../../Network";
+import { BatchedArray } from "array-batcher";
const unsupported = ["text/html", "text/plain"];
+interface FileResponse {
+ name: string;
+ path: string;
+ type: string;
+}
+
@observer
export default class DirectoryImportBox extends React.Component<FieldViewProps> {
private selector = React.createRef<HTMLInputElement>();
@observable private top = 0;
@observable private left = 0;
private dimensions = 50;
+ @observable private phase = "";
+ private disposer: Opt<IReactionDisposer>;
@observable private entries: ImportMetadataEntry[] = [];
@observable private quota = 1;
- @observable private remaining = 1;
+ @observable private completed = 0;
@observable private uploading = false;
@observable private removeHover = false;
@@ -66,15 +78,17 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
}
handleSelection = async (e: React.ChangeEvent<HTMLInputElement>) => {
- runInAction(() => this.uploading = true);
+ runInAction(() => {
+ this.uploading = true;
+ this.phase = "Initializing download...";
+ });
- let promises: Promise<void>[] = [];
let docs: Doc[] = [];
let files = e.target.files;
if (!files || files.length === 0) return;
- let directory = (files.item(0) as any).webkitRelativePath.split("/", 1);
+ let directory = (files.item(0) as any).webkitRelativePath.split("/", 1)[0];
let validated: File[] = [];
for (let i = 0; i < files.length; i++) {
@@ -82,37 +96,41 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
file && !unsupported.includes(file.type) && validated.push(file);
}
- runInAction(() => this.quota = validated.length);
-
- let sizes = [];
- let modifiedDates = [];
+ runInAction(() => {
+ this.quota = validated.length;
+ this.completed = 0;
+ });
- for (let uploaded_file of validated) {
- let formData = new FormData();
- formData.append('file', uploaded_file);
- let dropFileName = uploaded_file ? uploaded_file.name : "-empty-";
- let type = uploaded_file.type;
+ let sizes: number[] = [];
+ let modifiedDates: number[] = [];
- sizes.push(uploaded_file.size);
- modifiedDates.push(uploaded_file.lastModified);
+ runInAction(() => this.phase = `Internal: uploading ${this.quota - this.completed} files to Dash...`);
- runInAction(() => this.remaining++);
+ const uploads = await BatchedArray.from(validated, { batchSize: 15 }).batchedMapAsync(async batch => {
+ const formData = new FormData();
- let prom = fetch(Utils.prepend(RouteStore.upload), {
- method: 'POST',
- body: formData
- }).then(async (res: Response) => {
- (await res.json()).map(action((file: any) => {
- let docPromise = Docs.Get.DocumentFromType(type, Utils.prepend(file), { nativeWidth: 300, width: 300, title: dropFileName });
- docPromise.then(doc => {
- doc && docs.push(doc) && runInAction(() => this.remaining--);
- });
- }));
+ batch.forEach(file => {
+ sizes.push(file.size);
+ modifiedDates.push(file.lastModified);
+ formData.append(Utils.GenerateGuid(), file);
});
- promises.push(prom);
- }
- await Promise.all(promises);
+ const responses = await Identified.PostFormDataToServer(RouteStore.upload, formData);
+ runInAction(() => this.completed += batch.length);
+ return responses as FileResponse[];
+ });
+
+ await Promise.all(uploads.map(async upload => {
+ const type = upload.type;
+ const path = Utils.prepend(upload.path);
+ const options = {
+ nativeWidth: 300,
+ width: 300,
+ title: upload.name
+ };
+ const document = await Docs.Get.DocumentFromType(type, path, options);
+ document && docs.push(document);
+ }));
for (let i = 0; i < docs.length; i++) {
let doc = docs[i];
@@ -134,25 +152,41 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
x: NumCast(doc.x),
y: NumCast(doc.y) + offset
};
- if (this.props.ContainingCollectionDoc) {
- let importContainer = Docs.Create.StackingDocument(docs, options);
+ let parent = this.props.ContainingCollectionView;
+ if (parent) {
+ let importContainer: Doc;
+ if (docs.length < 50) {
+ importContainer = Docs.Create.MasonryDocument(docs, options);
+ } else {
+ const headers = [new SchemaHeaderField("title"), new SchemaHeaderField("size")];
+ importContainer = Docs.Create.SchemaDocument(headers, docs, options);
+ }
+ runInAction(() => this.phase = 'External: uploading files to Google Photos...');
importContainer.singleColumn = false;
- Doc.AddDocToList(Doc.GetProto(this.props.ContainingCollectionDoc), "data", importContainer);
+ await GooglePhotos.Export.CollectionToAlbum({ collection: importContainer });
+ Doc.AddDocToList(Doc.GetProto(parent.props.Document), "data", importContainer);
!this.persistent && this.props.removeDocument && this.props.removeDocument(doc);
DocumentManager.Instance.jumpToDocument(importContainer, true);
-
}
runInAction(() => {
this.uploading = false;
this.quota = 1;
- this.remaining = 1;
+ this.completed = 0;
});
}
componentDidMount() {
this.selector.current!.setAttribute("directory", "");
this.selector.current!.setAttribute("webkitdirectory", "");
+ this.disposer = reaction(
+ () => this.completed,
+ completed => runInAction(() => this.phase = `Internal: uploading ${this.quota - completed} files to Dash...`)
+ );
+ }
+
+ componentWillUnmount() {
+ this.disposer && this.disposer();
}
@action
@@ -187,7 +221,6 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
metadata.splice(index, 1);
}
}
-
}
}
@@ -195,19 +228,47 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
let dimensions = 50;
let entries = DocListCast(this.props.Document.data);
let isEditing = this.editingMetadata;
- let remaining = this.remaining;
+ let completed = this.completed;
let quota = this.quota;
let uploading = this.uploading;
let showRemoveLabel = this.removeHover;
let persistent = this.persistent;
- let percent = `${100 - (remaining / quota * 100)}`;
+ let percent = `${completed / quota * 100}`;
percent = percent.split(".")[0];
percent = percent.startsWith("100") ? "99" : percent;
let marginOffset = (percent.length === 1 ? 5 : 0) - 1.6;
+ const message = <span className={"phase"}>{this.phase}</span>;
+ const centerPiece = this.phase.includes("Google Photos") ?
+ <img src={"/assets/google_photos.png"} style={{
+ transition: "0.4s opacity ease",
+ width: 30,
+ height: 30,
+ opacity: uploading ? 1 : 0,
+ pointerEvents: "none",
+ position: "absolute",
+ left: 12,
+ top: this.top + 10,
+ fontSize: 18,
+ color: "white",
+ marginLeft: this.left + marginOffset
+ }} />
+ : <div
+ style={{
+ transition: "0.4s opacity ease",
+ opacity: uploading ? 1 : 0,
+ pointerEvents: "none",
+ position: "absolute",
+ left: 10,
+ top: this.top + 12.3,
+ fontSize: 18,
+ color: "white",
+ marginLeft: this.left + marginOffset
+ }}>{percent}%</div>;
return (
<Measure offset onResize={this.preserveCentering}>
{({ measureRef }) =>
<div ref={measureRef} style={{ width: "100%", height: "100%", pointerEvents: "all" }} >
+ {message}
<input
id={"selector"}
ref={this.selector}
@@ -280,18 +341,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
opacity: showRemoveLabel ? 1 : 0,
transition: "0.4s opacity ease"
}}>Template will be <span style={{ textDecoration: "underline", textDecorationColor: persistent ? "green" : "red", color: persistent ? "green" : "red" }}>{persistent ? "kept" : "removed"}</span> after upload</p>
- <div
- style={{
- transition: "0.4s opacity ease",
- opacity: uploading ? 1 : 0,
- pointerEvents: "none",
- position: "absolute",
- left: 10,
- top: this.top + 12.3,
- fontSize: 18,
- color: "white",
- marginLeft: this.left + marginOffset
- }}>{percent}%</div>
+ {centerPiece}
<div
style={{
position: "absolute",
@@ -312,7 +362,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps>
style={{
pointerEvents: "none",
position: "absolute",
- right: isEditing ? 16.3 : 14.5,
+ right: isEditing ? 14 : 15,
top: isEditing ? 15.4 : 16,
opacity: uploading ? 0 : 1,
transition: "0.4s opacity ease"
diff --git a/src/client/util/ProsemirrorExampleTransfer.ts b/src/client/util/ProsemirrorExampleTransfer.ts
index aab437176..dd0f72af0 100644
--- a/src/client/util/ProsemirrorExampleTransfer.ts
+++ b/src/client/util/ProsemirrorExampleTransfer.ts
@@ -107,8 +107,6 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, mapKeys?:
bind("Mod-s", TooltipTextMenu.insertStar);
-
-
bind("Tab", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => {
var ref = state.selection;
var range = ref.$from.blockRange(ref.$to);
diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx
index 710d55605..64821d8db 100644
--- a/src/client/util/RichTextSchema.tsx
+++ b/src/client/util/RichTextSchema.tsx
@@ -135,6 +135,7 @@ export const nodes: { [index: string]: NodeSpec } = {
alt: { default: null },
title: { default: null },
float: { default: "left" },
+ location: { default: "onRight" },
docid: { default: "" }
},
group: "inline",
@@ -619,6 +620,7 @@ export class ImageResizeView {
e.preventDefault();
e.stopPropagation();
DocServer.GetRefField(node.attrs.docid).then(async linkDoc => {
+ const location = node.attrs.location;
if (linkDoc instanceof Doc) {
let proto = Doc.GetProto(linkDoc);
let targetContext = await Cast(proto.targetContext, Doc);
@@ -780,10 +782,10 @@ export class FootnoteView {
if (!tr.getMeta("fromOutside")) {
let outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1);
- for (let i = 0; i < transactions.length; i++) {
- let steps = transactions[i].steps;
- for (let j = 0; j < steps.length; j++) {
- outerTr.step(steps[j].map(offsetMap));
+ for (let transaction of transactions) {
+ let steps = transaction.steps;
+ for (let step of steps) {
+ outerTr.step(step.map(offsetMap));
}
}
if (outerTr.docChanged) this.outerView.dispatch(outerTr);
diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss
new file mode 100644
index 000000000..9a4c5db30
--- /dev/null
+++ b/src/client/util/SharingManager.scss
@@ -0,0 +1,136 @@
+.sharing-interface {
+ display: flex;
+ flex-direction: column;
+
+ p {
+ font-size: 20px;
+ text-align: left;
+ font-style: italic;
+ padding: 0;
+ margin: 0 0 20px 0;
+ }
+
+ .hr-substitute {
+ border: solid black 0.5px;
+ margin-top: 20px;
+ }
+
+ .people-with-container {
+ display: flex;
+ height: 25px;
+
+ .people-with {
+ font-size: 14px;
+ margin: 0;
+ padding-top: 3px;
+ font-style: normal;
+ }
+
+ .people-with-select {
+ width: 126px;
+ outline: none;
+ }
+ }
+
+ .share-individual {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ }
+
+ .users-list {
+ font-style: italic;
+ background: white;
+ border: 1px solid black;
+ padding-left: 10px;
+ padding-right: 10px;
+ max-height: 200px;
+ overflow: scroll;
+ height: -webkit-fill-available;
+ text-align: left;
+ display: flex;
+ align-content: center;
+ align-items: center;
+ text-align: center;
+ justify-content: center;
+ color: red;
+ }
+
+ .container {
+ display: block;
+ position: relative;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ font-size: 22px;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ width: 700px;
+ min-width: 700px;
+ max-width: 700px;
+ text-align: left;
+ font-style: normal;
+ font-size: 15;
+ font-weight: normal;
+ padding: 0;
+
+ .padding {
+ padding: 0 0 0 20px;
+ color: black;
+ }
+
+ .permissions-dropdown {
+ outline: none;
+ }
+ }
+
+ .no-users {
+ margin-top: 20px;
+ }
+
+ .link-container {
+ display: flex;
+ flex-direction: row;
+ margin-bottom: 10px;
+ margin-left: auto;
+ margin-right: auto;
+
+ .link-box,
+ .copy {
+ padding: 10px;
+ border-radius: 10px;
+ padding: 10px;
+ border: solid black 1px;
+ }
+
+ .link-box {
+ background: white;
+ color: blue;
+ text-decoration: underline;
+ }
+
+ .copy {
+ margin-left: 20px;
+ cursor: alias;
+ border-radius: 50%;
+ width: 42px;
+ height: 42px;
+ transition: 1.5s all ease;
+ padding-top: 12px;
+ }
+ }
+
+ .close-button {
+ border-radius: 5px;
+ margin-top: 20px;
+ padding: 10px 0;
+ background: aliceblue;
+ transition: 0.5s ease all;
+ border: 1px solid;
+ border-color: aliceblue;
+ }
+
+ .close-button:hover {
+ border-color: black;
+ }
+} \ No newline at end of file
diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx
new file mode 100644
index 000000000..f427e40b1
--- /dev/null
+++ b/src/client/util/SharingManager.tsx
@@ -0,0 +1,293 @@
+import { observable, runInAction, action, autorun } from "mobx";
+import * as React from "react";
+import MainViewModal from "../views/MainViewModal";
+import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils";
+import { Doc, Opt } from "../../new_fields/Doc";
+import { DocServer } from "../DocServer";
+import { Cast, StrCast } from "../../new_fields/Types";
+import { listSpec } from "../../new_fields/Schema";
+import { List } from "../../new_fields/List";
+import { RouteStore } from "../../server/RouteStore";
+import * as RequestPromise from "request-promise";
+import { Utils } from "../../Utils";
+import "./SharingManager.scss";
+import { Id } from "../../new_fields/FieldSymbols";
+import { observer } from "mobx-react";
+import { MainView } from "../views/MainView";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { library } from '@fortawesome/fontawesome-svg-core';
+import * as fa from '@fortawesome/free-solid-svg-icons';
+import { DocumentView } from "../views/nodes/DocumentView";
+import { SelectionManager } from "./SelectionManager";
+import { DocumentManager } from "./DocumentManager";
+import { CollectionVideoView } from "../views/collections/CollectionVideoView";
+import { CollectionPDFView } from "../views/collections/CollectionPDFView";
+import { CollectionView } from "../views/collections/CollectionView";
+
+library.add(fa.faCopy);
+
+export interface User {
+ email: string;
+ userDocumentId: string;
+}
+
+export enum SharingPermissions {
+ None = "Not Shared",
+ View = "Can View",
+ Comment = "Can Comment",
+ Edit = "Can Edit"
+}
+
+const ColorMapping = new Map<string, string>([
+ [SharingPermissions.None, "red"],
+ [SharingPermissions.View, "maroon"],
+ [SharingPermissions.Comment, "blue"],
+ [SharingPermissions.Edit, "green"]
+]);
+
+const SharingKey = "sharingPermissions";
+const PublicKey = "publicLinkPermissions";
+const DefaultColor = "black";
+
+@observer
+export default class SharingManager extends React.Component<{}> {
+ public static Instance: SharingManager;
+ @observable private isOpen = false;
+ @observable private users: User[] = [];
+ @observable private targetDoc: Doc | undefined;
+ @observable private targetDocView: DocumentView | undefined;
+ @observable private copied = false;
+ @observable private dialogueBoxOpacity = 1;
+ @observable private overlayOpacity = 0.4;
+
+ private get linkVisible() {
+ return this.sharingDoc ? this.sharingDoc[PublicKey] !== SharingPermissions.None : false;
+ }
+
+ public open = (target: DocumentView) => {
+ SelectionManager.DeselectAll();
+ this.populateUsers().then(action(() => {
+ this.targetDocView = target;
+ this.targetDoc = target.props.Document;
+ MainView.Instance.hasActiveModal = true;
+ this.isOpen = true;
+ if (!this.sharingDoc) {
+ this.sharingDoc = new Doc;
+ }
+ }));
+ }
+
+ public close = action(() => {
+ this.isOpen = false;
+ setTimeout(action(() => {
+ this.copied = false;
+ MainView.Instance.hasActiveModal = false;
+ this.targetDoc = undefined;
+ }), 500);
+ });
+
+ private get sharingDoc() {
+ return this.targetDoc ? Cast(this.targetDoc[SharingKey], Doc) as Doc : undefined;
+ }
+
+ private set sharingDoc(value: Doc | undefined) {
+ this.targetDoc && (this.targetDoc[SharingKey] = value);
+ }
+
+ constructor(props: {}) {
+ super(props);
+ SharingManager.Instance = this;
+ }
+
+ populateUsers = async () => {
+ let userList = await RequestPromise.get(Utils.prepend(RouteStore.getUsers));
+ runInAction(() => {
+ this.users = (JSON.parse(userList) as User[]).filter(({ email }) => email !== Doc.CurrentUserEmail);
+ });
+ }
+
+ setInternalSharing = async (user: User, state: string) => {
+ if (!this.sharingDoc) {
+ console.log("SHARING ABORTED!");
+ return;
+ }
+ let sharingDoc = await this.sharingDoc;
+ sharingDoc[user.userDocumentId] = state;
+ const userDocument = await DocServer.GetRefField(user.userDocumentId);
+ if (!(userDocument instanceof Doc)) {
+ console.log(`Couldn't get user document of user ${user.email}`);
+ return;
+ }
+ let target = this.targetDoc;
+ if (!target) {
+ console.log("SharingManager trying to share an undefined document!!");
+ return;
+ }
+ const notifDoc = await Cast(userDocument.optionalRightCollection, Doc);
+ if (notifDoc instanceof Doc) {
+ const data = await Cast(notifDoc.data, listSpec(Doc));
+ if (!data) {
+ console.log("UNABLE TO ACCESS NOTIFICATION DATA");
+ return;
+ }
+ console.log(`Attempting to set permissions to ${state} for the document ${target[Id]}`);
+ if (state !== SharingPermissions.None) {
+ const sharedDoc = Doc.MakeAlias(target);
+ if (data) {
+ data.push(sharedDoc);
+ } else {
+ notifDoc.data = new List([sharedDoc]);
+ }
+ } else {
+ let dataDocs = (await Promise.all(data.map(doc => doc))).map(doc => Doc.GetProto(doc));
+ if (dataDocs.includes(target)) {
+ console.log("Searching in ", dataDocs, "for", target);
+ dataDocs.splice(dataDocs.indexOf(target), 1);
+ console.log("SUCCESSFULLY UNSHARED DOC");
+ } else {
+ console.log("DIDN'T THINK WE HAD IT, SO NOT SUCCESSFULLY UNSHARED");
+ }
+ }
+ }
+ }
+
+ private setExternalSharing = (state: string) => {
+ let sharingDoc = this.sharingDoc;
+ if (!sharingDoc) {
+ return;
+ }
+ sharingDoc[PublicKey] = state;
+ }
+
+ private get sharingUrl() {
+ if (!this.targetDoc) {
+ return undefined;
+ }
+ let baseUrl = Utils.prepend("/doc/" + this.targetDoc[Id]);
+ return `${baseUrl}?sharing=true`;
+ }
+
+ copy = action(() => {
+ if (this.sharingUrl) {
+ Utils.CopyText(this.sharingUrl);
+ this.copied = true;
+ }
+ });
+
+ private get sharingOptions() {
+ return Object.values(SharingPermissions).map(permission => {
+ return (
+ <option key={permission} value={permission}>
+ {permission}
+ </option>
+ );
+ });
+ }
+
+ private focusOn = (contents: string) => {
+ let title = this.targetDoc ? StrCast(this.targetDoc.title) : "";
+ return (
+ <span
+ title={title}
+ onClick={() => {
+ let context: Opt<CollectionVideoView | CollectionPDFView | CollectionView>;
+ if (this.targetDoc && this.targetDocView && (context = this.targetDocView.props.ContainingCollectionView)) {
+ DocumentManager.Instance.jumpToDocument(this.targetDoc, true, undefined, undefined, undefined, context.props.Document);
+ }
+ }}
+ onPointerEnter={action(() => {
+ if (this.targetDoc) {
+ Doc.BrushDoc(this.targetDoc);
+ this.dialogueBoxOpacity = 0.1;
+ this.overlayOpacity = 0.1;
+ }
+ })}
+ onPointerLeave={action(() => {
+ this.targetDoc && Doc.UnBrushDoc(this.targetDoc);
+ this.dialogueBoxOpacity = 1;
+ this.overlayOpacity = 0.4;
+ })}
+ >
+ {contents}
+ </span>
+ );
+ }
+
+ private get sharingInterface() {
+ return (
+ <div className={"sharing-interface"}>
+ <p className={"share-link"}>Manage the public link to {this.focusOn("this document...")}</p>
+ {!this.linkVisible ? (null) :
+ <div className={"link-container"}>
+ <div className={"link-box"} onClick={this.copy}>{this.sharingUrl}</div>
+ <div
+ title={"Copy link to clipboard"}
+ className={"copy"}
+ style={{ backgroundColor: this.copied ? "lawngreen" : "gainsboro" }}
+ onClick={this.copy}
+ >
+ <FontAwesomeIcon icon={fa.faCopy} />
+ </div>
+ </div>
+ }
+ <div className={"people-with-container"}>
+ {!this.linkVisible ? (null) : <p className={"people-with"}>People with this link</p>}
+ <select
+ className={"people-with-select"}
+ value={this.sharingDoc ? StrCast(this.sharingDoc[PublicKey], SharingPermissions.None) : SharingPermissions.None}
+ style={{
+ marginLeft: this.linkVisible ? 10 : 0,
+ color: this.sharingDoc ? ColorMapping.get(StrCast(this.sharingDoc[PublicKey], SharingPermissions.None)) : DefaultColor,
+ borderColor: this.sharingDoc ? ColorMapping.get(StrCast(this.sharingDoc[PublicKey], SharingPermissions.None)) : DefaultColor
+ }}
+ onChange={e => this.setExternalSharing(e.currentTarget.value)}
+ >
+ {this.sharingOptions}
+ </select>
+ </div>
+ <div className={"hr-substitute"} />
+ <p className={"share-individual"}>Privately share {this.focusOn("this document")} with an individual...</p>
+ <div className={"users-list"} style={{ display: this.users.length ? "block" : "flex" }}>
+ {!this.users.length ? "There are no other users in your database." :
+ this.users.map(user => {
+ return (
+ <div
+ key={user.email}
+ className={"container"}
+ >
+ <select
+ className={"permissions-dropdown"}
+ value={this.sharingDoc ? StrCast(this.sharingDoc[user.userDocumentId], SharingPermissions.None) : SharingPermissions.None}
+ style={{
+ color: this.sharingDoc ? ColorMapping.get(StrCast(this.sharingDoc[user.userDocumentId], SharingPermissions.None)) : DefaultColor,
+ borderColor: this.sharingDoc ? ColorMapping.get(StrCast(this.sharingDoc[user.userDocumentId], SharingPermissions.None)) : DefaultColor
+ }}
+ onChange={e => this.setInternalSharing(user, e.currentTarget.value)}
+ >
+ {this.sharingOptions}
+
+ </select>
+ <span className={"padding"}>{user.email}</span>
+ </div>
+ );
+ })
+ }
+ </div>
+ <div className={"close-button"} onClick={this.close}>Done</div>
+ </div>
+ );
+ }
+
+ render() {
+ return (
+ <MainViewModal
+ contents={this.sharingInterface}
+ isDisplayed={this.isOpen}
+ interactive={true}
+ dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity}
+ overlayDisplayedOpacity={this.overlayOpacity}
+ />
+ );
+ }
+
+} \ No newline at end of file
diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx
index 9ca54f738..338f6b83e 100644
--- a/src/client/views/DocumentButtonBar.tsx
+++ b/src/client/views/DocumentButtonBar.tsx
@@ -23,7 +23,6 @@ import React = require("react");
import { DocumentView } from './nodes/DocumentView';
import { ParentDocSelector } from './collections/ParentDocumentSelector';
import { CollectionDockingView } from './collections/CollectionDockingView';
-import { DocumentDecorations } from './DocumentDecorations';
const higflyout = require("@hig/flyout");
export const { anchorPoints } = higflyout;
export const Flyout = higflyout.default;
diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss
index e68bfc6ad..32346165d 100644
--- a/src/client/views/DocumentDecorations.scss
+++ b/src/client/views/DocumentDecorations.scss
@@ -266,6 +266,31 @@ $linkGap : 3px;
}
}
-@-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
+@-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);
+ }
+}
+
+@keyframes shadow-pulse {
+ 0% {
+ box-shadow: 0 0 0 0px rgba(0, 0, 0, 0.8);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 10px rgba(0, 255, 0, 0);
+ }
+} \ No newline at end of file
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx
index 944ae586c..9d42eb719 100644
--- a/src/client/views/DocumentDecorations.tsx
+++ b/src/client/views/DocumentDecorations.tsx
@@ -68,6 +68,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
@observable public pullIcon: IconProp = "arrow-alt-circle-down";
@observable public pullColor: string = "white";
@observable public isAnimatingFetch = false;
+ @observable public isAnimatingPulse = false;
@observable public openHover = false;
constructor(props: Readonly<{}>) {
@@ -581,8 +582,6 @@ export class DocumentDecorations extends React.Component<{}, { value: string }>
return "-unset-";
}
-
-
render() {
var bounds = this.Bounds;
let seldoc = SelectionManager.SelectedDocuments().length ? SelectionManager.SelectedDocuments()[0] : undefined;
diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts
index 2fa03e969..c519991a5 100644
--- a/src/client/views/GlobalKeyHandler.ts
+++ b/src/client/views/GlobalKeyHandler.ts
@@ -6,6 +6,7 @@ import { DragManager } from "../util/DragManager";
import { action, runInAction } from "mobx";
import { Doc } from "../../new_fields/Doc";
import { DictationManager } from "../util/DictationManager";
+import SharingManager from "../util/SharingManager";
const modifiers = ["control", "meta", "shift", "alt"];
type KeyHandler = (keycode: string, e: KeyboardEvent) => KeyControlInfo | Promise<KeyControlInfo>;
@@ -72,6 +73,7 @@ export default class KeyManager {
main.toggleColorPicker(true);
SelectionManager.DeselectAll();
DictationManager.Controls.stop();
+ SharingManager.Instance.close();
break;
case "delete":
case "backspace":
diff --git a/src/client/views/InkingCanvas.scss b/src/client/views/InkingCanvas.scss
index b3edad459..9cc220a1d 100644
--- a/src/client/views/InkingCanvas.scss
+++ b/src/client/views/InkingCanvas.scss
@@ -35,7 +35,7 @@
.inkingCanvas-noSelect {
pointer-events: none;
- cursor: "arrow";
+ cursor: "crosshair";
}
.inkingCanvas-paths-ink,
diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss
index bc0975c86..04249506a 100644
--- a/src/client/views/Main.scss
+++ b/src/client/views/Main.scss
@@ -268,44 +268,4 @@ ul#add-options-list {
height: 25%;
position: relative;
display: flex;
-}
-
-.dictation-prompt {
- position: absolute;
- z-index: 1000;
- text-align: center;
- justify-content: center;
- align-self: center;
- align-content: center;
- padding: 20px;
- background: gainsboro;
- border-radius: 10px;
- border: 3px solid black;
- box-shadow: #00000044 5px 5px 10px;
- transform: translate(-50%, -50%);
- top: 50%;
- font-style: italic;
- left: 50%;
- transition: 0.5s all ease;
- pointer-events: none;
-}
-
-.dictation-prompt-overlay {
- width: 100%;
- height: 100%;
- position: absolute;
- z-index: 999;
- transition: 0.5s all ease;
- pointer-events: none;
-}
-
-.webpage-input {
- display: none;
- height: 60px;
- width: 600px;
- position: absolute;
-
- .url-input {
- width: 80%;
- }
} \ No newline at end of file
diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx
index 11ec6f0c9..3bd898ac0 100644
--- a/src/client/views/Main.tsx
+++ b/src/client/views/Main.tsx
@@ -7,6 +7,9 @@ import { Cast } from "../../new_fields/Types";
import { Doc, DocListCastAsync } from "../../new_fields/Doc";
import { List } from "../../new_fields/List";
import { DocServer } from "../DocServer";
+import { AssignAllExtensions } from "../../extensions/General/Extensions";
+
+AssignAllExtensions();
let swapDocs = async () => {
let oldDoc = await Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc);
@@ -32,12 +35,16 @@ let swapDocs = async () => {
const info = await CurrentUserUtils.loadCurrentUser();
DocServer.init(window.location.protocol, window.location.hostname, 4321, info.email);
await Docs.Prototypes.initialize();
- await CurrentUserUtils.loadUserDocument(info);
- // updates old user documents to prevent chrome on tree view.
- (await Cast(CurrentUserUtils.UserDocument.workspaces, Doc))!.chromeStatus = "disabled";
- (await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))!.chromeStatus = "disabled";
- (await Cast(CurrentUserUtils.UserDocument.sidebar, Doc))!.chromeStatus = "disabled";
- await swapDocs();
+ if (info.id !== "__guest__") {
+ // a guest will not have an id registered
+ await CurrentUserUtils.loadUserDocument(info);
+ // updates old user documents to prevent chrome on tree view.
+ (await Cast(CurrentUserUtils.UserDocument.workspaces, Doc))!.chromeStatus = "disabled";
+ (await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))!.chromeStatus = "disabled";
+ (await Cast(CurrentUserUtils.UserDocument.sidebar, Doc))!.chromeStatus = "disabled";
+ CurrentUserUtils.UserDocument.chromeStatus = "disabled";
+ await swapDocs();
+ }
document.getElementById('root')!.addEventListener('wheel', event => {
if (event.ctrlKey) {
event.preventDefault();
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 0d1546e68..cf09bd2d0 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -1,5 +1,5 @@
import { IconName, library } from '@fortawesome/fontawesome-svg-core';
-import { faLink, faArrowDown, faArrowUp, faBolt, faCaretUp, faCat, faCheck, faClone, faCloudUploadAlt, faCommentAlt, faCut, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faLongArrowAltRight, faMusic, faObjectGroup, faPause, faPenNib, faPlay, faPortrait, faRedoAlt, faThumbtack, faTree, faUndoAlt, faTv } from '@fortawesome/free-solid-svg-icons';
+import { faArrowDown, faArrowUp, faBolt, faCaretUp, faCat, faCheck, faClone, faCloudUploadAlt, faCommentAlt, faCut, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faLongArrowAltRight, faMusic, faObjectGroup, faPause, faPenNib, faPlay, faPortrait, faRedoAlt, faThumbtack, faTree, faUndoAlt, faTv } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { action, computed, configure, observable, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
@@ -7,8 +7,8 @@ import "normalize.css";
import * as React from 'react';
import { SketchPicker } from 'react-color';
import Measure from 'react-measure';
-import { Doc, DocListCast, Opt, HeightSym } from '../../new_fields/Doc';
import { List } from '../../new_fields/List';
+import { Doc, DocListCast, Opt, HeightSym, FieldResult, Field } from '../../new_fields/Doc';
import { Id } from '../../new_fields/FieldSymbols';
import { InkTool } from '../../new_fields/InkField';
import { listSpec } from '../../new_fields/Schema';
@@ -17,14 +17,14 @@ import { CurrentUserUtils } from '../../server/authentication/models/current_use
import { RouteStore } from '../../server/RouteStore';
import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString } from '../../Utils';
import { DocServer } from '../DocServer';
-import { Docs } from '../documents/Documents';
import { ClientUtils } from '../util/ClientUtils';
import { DictationManager } from '../util/DictationManager';
import { SetupDrag } from '../util/DragManager';
-import { HistoryUtil } from '../util/History';
import { Transform } from '../util/Transform';
import { UndoManager, undoBatch } from '../util/UndoManager';
-import { CollectionBaseView } from './collections/CollectionBaseView';
+import { Docs, DocumentOptions } from '../documents/Documents';
+import { HistoryUtil } from '../util/History';
+import { CollectionBaseView, CollectionViewType } from './collections/CollectionBaseView';
import { CollectionDockingView } from './collections/CollectionDockingView';
import { CollectionTreeView } from './collections/CollectionTreeView';
import { ContextMenu } from './ContextMenu';
@@ -40,8 +40,13 @@ import { PreviewCursor } from './PreviewCursor';
import { FilterBox } from './search/FilterBox';
import PresModeMenu from './presentationview/PresentationModeMenu';
import { PresBox } from './nodes/PresBox';
+import { GooglePhotos } from '../apis/google_docs/GooglePhotosClientUtils';
+import { ImageField } from '../../new_fields/URLField';
import { LinkFollowBox } from './linking/LinkFollowBox';
import { DocumentManager } from '../util/DocumentManager';
+import { SchemaHeaderField, RandomPastel } from '../../new_fields/SchemaHeaderField';
+import MainViewModal from './MainViewModal';
+import SharingManager from '../util/SharingManager';
@observer
export class MainView extends React.Component {
@@ -55,6 +60,8 @@ export class MainView extends React.Component {
@observable private dictationDisplayState = false;
@observable private dictationListeningState: DictationManager.Controls.ListeningUIStatus = false;
+ public hasActiveModal = false;
+
public overlayTimeout: NodeJS.Timeout | undefined;
public initiateDictationFade = () => {
@@ -62,10 +69,17 @@ export class MainView extends React.Component {
this.overlayTimeout = setTimeout(() => {
this.dictationOverlayVisible = false;
this.dictationSuccess = undefined;
+ this.hasActiveModal = false;
setTimeout(() => this.dictatedPhrase = DictationManager.placeholder, 500);
}, duration);
}
+ private urlState: HistoryUtil.DocUrl;
+
+ @computed private get userDoc() {
+ return CurrentUserUtils.UserDocument;
+ }
+
public cancelDictationFade = () => {
if (this.overlayTimeout) {
clearTimeout(this.overlayTimeout);
@@ -74,7 +88,7 @@ export class MainView extends React.Component {
}
@computed private get mainContainer(): Opt<Doc> {
- return FieldValue(Cast(CurrentUserUtils.UserDocument.activeWorkspace, Doc));
+ return this.userDoc ? FieldValue(Cast(this.userDoc.activeWorkspace, Doc)) : CurrentUserUtils.GuestWorkspace;
}
@computed get mainFreeform(): Opt<Doc> {
let docs = DocListCast(this.mainContainer!.data);
@@ -83,7 +97,10 @@ export class MainView extends React.Component {
public isPointerDown = false;
private set mainContainer(doc: Opt<Doc>) {
if (doc) {
- CurrentUserUtils.UserDocument.activeWorkspace = doc;
+ if (!("presentationView" in doc)) {
+ doc.presentationView = new List<Doc>([Docs.Create.TreeDocument([], { title: "Presentation" })]);
+ }
+ this.userDoc ? (this.userDoc.activeWorkspace = doc) : (CurrentUserUtils.GuestWorkspace = doc);
}
}
@@ -128,21 +145,23 @@ export class MainView extends React.Component {
window.removeEventListener("keydown", KeyManager.Instance.handle);
window.addEventListener("keydown", KeyManager.Instance.handle);
- reaction(() => {
- let workspaces = CurrentUserUtils.UserDocument.workspaces;
- let recent = CurrentUserUtils.UserDocument.recentlyClosed;
- if (!(recent instanceof Doc)) return 0;
- if (!(workspaces instanceof Doc)) return 0;
- let workspacesDoc = workspaces;
- let recentDoc = recent;
- let libraryHeight = this.getPHeight() - workspacesDoc[HeightSym]() - recentDoc[HeightSym]() - 20 + CurrentUserUtils.UserDocument[HeightSym]() * 0.00001;
- return libraryHeight;
- }, (libraryHeight: number) => {
- if (libraryHeight && Math.abs(CurrentUserUtils.UserDocument[HeightSym]() - libraryHeight) > 5) {
- CurrentUserUtils.UserDocument.height = libraryHeight;
- }
- (Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc) as Doc).allowClear = true;
- }, { fireImmediately: true });
+ if (this.userDoc) {
+ reaction(() => {
+ let workspaces = this.userDoc.workspaces;
+ let recent = this.userDoc.recentlyClosed;
+ if (!(recent instanceof Doc)) return 0;
+ if (!(workspaces instanceof Doc)) return 0;
+ let workspacesDoc = workspaces;
+ let recentDoc = recent;
+ let libraryHeight = this.getPHeight() - workspacesDoc[HeightSym]() - recentDoc[HeightSym]() - 20 + this.userDoc[HeightSym]() * 0.00001;
+ return libraryHeight;
+ }, (libraryHeight: number) => {
+ if (libraryHeight && Math.abs(this.userDoc[HeightSym]() - libraryHeight) > 5) {
+ this.userDoc.height = libraryHeight;
+ }
+ (Cast(this.userDoc.recentlyClosed, Doc) as Doc).allowClear = true;
+ }, { fireImmediately: true });
+ }
}
componentWillUnMount() {
@@ -155,7 +174,7 @@ export class MainView extends React.Component {
constructor(props: Readonly<{}>) {
super(props);
MainView.Instance = this;
-
+ this.urlState = HistoryUtil.parseUrl(window.location) || {} as any;
// causes errors to be generated when modifying an observable outside of an action
configure({ enforceActions: "observed" });
if (window.location.pathname !== RouteStore.home) {
@@ -164,6 +183,12 @@ export class MainView extends React.Component {
let type = pathname[0];
if (type === "doc") {
CurrentUserUtils.MainDocId = pathname[1];
+ if (!this.userDoc) {
+ runInAction(() => this.flyoutWidth = 0);
+ DocServer.GetRefField(CurrentUserUtils.MainDocId).then(action(field => {
+ field instanceof Doc && (CurrentUserUtils.GuestTarget = field);
+ }));
+ }
}
}
}
@@ -220,69 +245,106 @@ export class MainView extends React.Component {
initAuthenticationRouters = async () => {
// Load the user's active workspace, or create a new one if initial session after signup
- if (!CurrentUserUtils.MainDocId) {
- const doc = await Cast(CurrentUserUtils.UserDocument.activeWorkspace, Doc);
- if (doc) {
+ let received = CurrentUserUtils.MainDocId;
+ if (received && !this.userDoc) {
+ reaction(
+ () => CurrentUserUtils.GuestTarget,
+ target => target && this.createNewWorkspace(),
+ { fireImmediately: true }
+ );
+ } else {
+ if (received && this.urlState.sharing) {
+ reaction(
+ () => {
+ let docking = CollectionDockingView.Instance;
+ return docking && docking.initialized;
+ },
+ initialized => {
+ if (initialized && received) {
+ DocServer.GetRefField(received).then(field => {
+ if (field instanceof Doc && field.viewType !== CollectionViewType.Docking) {
+ CollectionDockingView.AddRightSplit(field, undefined);
+ }
+ });
+ }
+ },
+ );
+ }
+ let doc: Opt<Doc>;
+ if (this.userDoc && (doc = await Cast(this.userDoc.activeWorkspace, Doc))) {
this.openWorkspace(doc);
} else {
this.createNewWorkspace();
}
- } else {
- DocServer.GetRefField(CurrentUserUtils.MainDocId).then(field => {
- field instanceof Doc ? this.openWorkspace(field) :
- this.createNewWorkspace(CurrentUserUtils.MainDocId);
- });
}
}
-
@action
createNewWorkspace = async (id?: string) => {
- let workspaces = Cast(CurrentUserUtils.UserDocument.workspaces, Doc);
- if (!(workspaces instanceof Doc)) return;
- const list = Cast((CurrentUserUtils.UserDocument.workspaces as Doc).data, listSpec(Doc));
- if (list) {
- let freeformDoc = Docs.Create.FreeformDocument([], { x: 0, y: 400, width: this.pwidth * .7, height: this.pheight, title: `WS collection ${list.length + 1}` });
- var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc, freeformDoc, 600)] }] };
- let mainDoc = Docs.Create.DockDocument([CurrentUserUtils.UserDocument, freeformDoc], JSON.stringify(dockingLayout), { title: `Workspace ${list.length + 1}` }, id);
- if (!CurrentUserUtils.UserDocument.linkManagerDoc) {
- let linkManagerDoc = new Doc();
- linkManagerDoc.allLinks = new List<Doc>([]);
- CurrentUserUtils.UserDocument.linkManagerDoc = linkManagerDoc;
+ let freeformOptions: DocumentOptions = {
+ x: 0,
+ y: 400,
+ width: this.pwidth * .7,
+ height: this.pheight,
+ title: "My Blank Collection"
+ };
+ let workspaces: FieldResult<Doc>;
+ let freeformDoc = CurrentUserUtils.GuestTarget || Docs.Create.FreeformDocument([], freeformOptions);
+ var dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc, freeformDoc, 600)] }] };
+ let mainDoc = Docs.Create.DockDocument([this.userDoc, freeformDoc], JSON.stringify(dockingLayout), {}, id);
+ if (this.userDoc && ((workspaces = Cast(this.userDoc.workspaces, Doc)) instanceof Doc)) {
+ const list = Cast((workspaces).data, listSpec(Doc));
+ if (list) {
+ if (!this.userDoc.linkManagerDoc) {
+ let linkManagerDoc = new Doc();
+ linkManagerDoc.allLinks = new List<Doc>([]);
+ this.userDoc.linkManagerDoc = linkManagerDoc;
+ }
+ list.push(mainDoc);
+ mainDoc.title = `Workspace ${list.length}`;
}
- list.push(mainDoc);
- // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container)
- setTimeout(() => {
- this.openWorkspace(mainDoc);
- // let pendingDocument = Docs.StackingDocument([], { title: "New Mobile Uploads" });
- // mainDoc.optionalRightCollection = pendingDocument;
- }, 0);
}
+ // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container)
+ setTimeout(() => {
+ this.openWorkspace(mainDoc);
+ // let pendingDocument = Docs.StackingDocument([], { title: "New Mobile Uploads" });
+ // mainDoc.optionalRightCollection = pendingDocument;
+ }, 0);
}
@action
openWorkspace = async (doc: Doc, fromHistory = false) => {
CurrentUserUtils.MainDocId = doc[Id];
this.mainContainer = doc;
- const state = HistoryUtil.parseUrl(window.location) || {} as any;
- fromHistory || HistoryUtil.pushState({ type: "doc", docId: doc[Id], readonly: state.readonly, nro: state.nro });
- if (state.readonly === true || state.readonly === null) {
+ let state = this.urlState;
+ if (state.sharing === true && !this.userDoc) {
DocServer.Control.makeReadOnly();
- } else if (state.safe) {
- if (!state.nro) {
+ } else {
+ fromHistory || HistoryUtil.pushState({
+ type: "doc",
+ docId: doc[Id],
+ readonly: state.readonly,
+ nro: state.nro,
+ sharing: false,
+ });
+ if (state.readonly === true || state.readonly === null) {
+ DocServer.Control.makeReadOnly();
+ } else if (state.safe) {
+ if (!state.nro) {
+ DocServer.Control.makeReadOnly();
+ }
+ CollectionBaseView.SetSafeMode(true);
+ } else if (state.nro || state.nro === null || state.readonly === false) {
+ } else if (BoolCast(doc.readOnly)) {
DocServer.Control.makeReadOnly();
+ } else {
+ DocServer.Control.makeEditable();
}
- CollectionBaseView.SetSafeMode(true);
- } else if (state.nro || state.nro === null || state.readonly === false) {
- } else if (BoolCast(doc.readOnly)) {
- DocServer.Control.makeReadOnly();
- } else {
- DocServer.Control.makeEditable();
}
- const col = await Cast(CurrentUserUtils.UserDocument.optionalRightCollection, Doc);
+ let col: Opt<Doc>;
// if there is a pending doc, and it has new data, show it (syip: we use a timeout to prevent collection docking view from being uninitialized)
setTimeout(async () => {
- if (col) {
+ if (this.userDoc && (col = await Cast(this.userDoc.optionalRightCollection, Doc))) {
const l = Cast(col.data, listSpec(Doc));
if (l) {
runInAction(() => CollectionTreeView.NotifsCol = col);
@@ -383,11 +445,12 @@ export class MainView extends React.Component {
}
@computed
get flyout() {
- let sidebar = CurrentUserUtils.UserDocument.sidebar;
- if (!(sidebar instanceof Doc)) return (null);
- let sidebarDoc = sidebar;
+ let sidebar: FieldResult<Field>;
+ if (!this.userDoc || !((sidebar = this.userDoc.sidebar) instanceof Doc)) {
+ return (null);
+ }
return <DocumentView
- Document={sidebarDoc}
+ Document={sidebar}
DataDoc={undefined}
addDocument={undefined}
addDocTab={this.addDocTabFunc}
@@ -413,9 +476,14 @@ export class MainView extends React.Component {
}
@computed
get mainContent() {
- let sidebar = CurrentUserUtils.UserDocument.sidebar;
- if (!(sidebar instanceof Doc)) return (null);
- return <div className="mainContent" style={{ width: "100%", height: "100%", position: "absolute" }}>
+ if (!this.userDoc) {
+ return <div>{this.dockingContent}</div>;
+ }
+ let sidebar = this.userDoc.sidebar;
+ if (!(sidebar instanceof Doc)) {
+ return (null);
+ }
+ return <div>
<div className="mainView-libraryHandle"
style={{ cursor: "ew-resize", left: `${this.flyoutWidth - 10}px`, backgroundColor: `${StrCast(sidebar.backgroundColor, "lightGray")}` }}
onPointerDown={this.onPointerDown}>
@@ -444,14 +512,22 @@ export class MainView extends React.Component {
}
}
- toggleLinkFollowBox = (shouldClose: boolean) => {
- if (LinkFollowBox.Instance) {
- let dvs = DocumentManager.Instance.getDocumentViews(LinkFollowBox.Instance.props.Document);
- // if it already exisits, close it
- LinkFollowBox.Instance.props.Document.isMinimized = (dvs.length > 0 && shouldClose);
- }
+ setWriteMode = (mode: DocServer.WriteMode) => {
+ console.log(DocServer.WriteMode[mode]);
+ const mode1 = mode;
+ const mode2 = mode === DocServer.WriteMode.Default ? mode : DocServer.WriteMode.Playground;
+ DocServer.setFieldWriteMode("x", mode1);
+ DocServer.setFieldWriteMode("y", mode1);
+ DocServer.setFieldWriteMode("width", mode1);
+ DocServer.setFieldWriteMode("height", mode1);
+
+ DocServer.setFieldWriteMode("panX", mode2);
+ DocServer.setFieldWriteMode("panY", mode2);
+ DocServer.setFieldWriteMode("scale", mode2);
+ DocServer.setFieldWriteMode("viewType", mode2);
}
+
@observable private _colorPickerDisplay = false;
/* for the expandable add nodes menu. Not included with the miscbuttons because once it expands it expands the whole div with it, making canvas interactions limited. */
nodesMenu() {
@@ -467,12 +543,15 @@ export class MainView extends React.Component {
// let youtubeurl = "https://www.youtube.com/embed/TqcApsGRzWw";
// let addYoutubeSearcher = action(() => Docs.Create.YoutubeDocument(youtubeurl, { width: 600, height: 600, title: "youtube search" }));
- let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc][] = [
+ // let googlePhotosSearch = () => GooglePhotosClientUtils.CollectionFromSearch(Docs.Create.MasonryDocument, { included: [GooglePhotosClientUtils.ContentCategories.LANDSCAPES] });
+
+ let btns: [React.RefObject<HTMLDivElement>, IconName, string, () => Doc | Promise<Doc>][] = [
[React.createRef<HTMLDivElement>(), "object-group", "Add Collection", addColNode],
[React.createRef<HTMLDivElement>(), "tv", "Add Presentation Trail", addPresNode],
[React.createRef<HTMLDivElement>(), "globe-asia", "Add Website", addWebNode],
[React.createRef<HTMLDivElement>(), "bolt", "Add Button", addButtonDocument],
[React.createRef<HTMLDivElement>(), "file", "Add Document Dragger", addDragboxNode],
+ // [React.createRef<HTMLDivElement>(), "object-group", "Test Google Photos Search", googlePhotosSearch],
[React.createRef<HTMLDivElement>(), "cloud-upload-alt", "Import Directory", addImportCollectionNode], //remove at some point in favor of addImportCollectionNode
//[React.createRef<HTMLDivElement>(), "play", "Add Youtube Searcher", addYoutubeSearcher],
];
@@ -494,7 +573,7 @@ export class MainView extends React.Component {
<FontAwesomeIcon icon={btn[1]} size="sm" />
</button>
</div></li>)}
- <li key="linkFollow"><button className="add-button round-button" title="Open Link Follower" onClick={() => this.toggleLinkFollowBox(true)}><FontAwesomeIcon icon="link" size="sm" /></button></li>
+ <li key="undoTest"><button className="add-button round-button" title="Click if undo isn't working" onClick={() => UndoManager.TraceOpenBatches()}><FontAwesomeIcon icon="exclamation" size="sm" /></button></li>
<li key="color"><button className="add-button round-button" title="Select Color" style={{ zIndex: 1000 }} onClick={() => this.toggleColorPicker()}><div className="toolbar-color-button" style={{ backgroundColor: InkingControl.Instance.selectedColor }} >
<div className="toolbar-color-picker" onClick={this.onColorClick} style={this._colorPickerDisplay ? { color: "black", display: "block" } : { color: "black", display: "none" }}>
<SketchPicker color={InkingControl.Instance.selectedColor} onChange={InkingControl.Instance.switchColor} />
@@ -505,7 +584,7 @@ export class MainView extends React.Component {
<li key="marker"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Highlighter)} title="Highlighter" style={this.selected(InkTool.Highlighter)}><FontAwesomeIcon icon="highlighter" size="lg" /></button></li>
<li key="eraser"><button onClick={() => InkingControl.Instance.switchTool(InkTool.Eraser)} title="Eraser" style={this.selected(InkTool.Eraser)}><FontAwesomeIcon icon="eraser" size="lg" /></button></li>
<li key="inkControls"><InkingControl /></li>
- <li key="logout"><button onClick={() => window.location.assign(Utils.prepend(RouteStore.logout))}>Log Out</button></li>
+ <li key="logout"><button onClick={() => window.location.assign(Utils.prepend(RouteStore.logout))}>{CurrentUserUtils.GuestWorkspace ? "Exit" : "Log Out"}</button></li>
</ul>
</div>
</div >;
@@ -533,28 +612,25 @@ export class MainView extends React.Component {
this.isSearchVisible = !this.isSearchVisible;
}
- private get dictationOverlay() {
- let display = this.dictationOverlayVisible;
+ @computed private get dictationOverlay() {
let success = this.dictationSuccess;
let result = this.isListening && !this.isListening.interim ? DictationManager.placeholder : `"${this.dictatedPhrase}"`;
+ let dialogueBoxStyle = {
+ background: success === undefined ? "gainsboro" : success ? "lawngreen" : "red",
+ borderColor: this.isListening ? "red" : "black",
+ fontStyle: "italic"
+ };
+ let overlayStyle = {
+ backgroundColor: this.isListening ? "red" : "darkslategrey"
+ };
return (
- <div>
- <div
- className={"dictation-prompt"}
- style={{
- opacity: display ? 1 : 0,
- background: success === undefined ? "gainsboro" : success ? "lawngreen" : "red",
- borderColor: this.isListening ? "red" : "black",
- }}
- >{result}</div>
- <div
- className={"dictation-prompt-overlay"}
- style={{
- opacity: display ? 0.4 : 0,
- backgroundColor: this.isListening ? "red" : "darkslategrey"
- }}
- />
- </div>
+ <MainViewModal
+ contents={result}
+ isDisplayed={this.dictationOverlayVisible}
+ interactive={false}
+ dialogueBoxStyle={dialogueBoxStyle}
+ overlayStyle={overlayStyle}
+ />
);
}
@@ -570,6 +646,7 @@ export class MainView extends React.Component {
return (
<div id="main-div">
{this.dictationOverlay}
+ <SharingManager />
<DocumentDecorations />
{this.mainContent}
{this.miniPresentation}
diff --git a/src/client/views/MainViewModal.scss b/src/client/views/MainViewModal.scss
new file mode 100644
index 000000000..f5a9ee76c
--- /dev/null
+++ b/src/client/views/MainViewModal.scss
@@ -0,0 +1,25 @@
+.dialogue-box {
+ position: absolute;
+ z-index: 1000;
+ text-align: center;
+ justify-content: center;
+ align-self: center;
+ align-content: center;
+ padding: 20px;
+ background: gainsboro;
+ border-radius: 10px;
+ border: 3px solid black;
+ box-shadow: #00000044 5px 5px 10px;
+ transform: translate(-50%, -50%);
+ top: 50%;
+ left: 50%;
+ transition: 0.5s all ease;
+}
+
+.overlay {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: 999;
+ transition: 0.5s all ease;
+} \ No newline at end of file
diff --git a/src/client/views/MainViewModal.tsx b/src/client/views/MainViewModal.tsx
new file mode 100644
index 000000000..b7fdd6168
--- /dev/null
+++ b/src/client/views/MainViewModal.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+import "./MainViewModal.scss";
+
+export interface MainViewOverlayProps {
+ isDisplayed: boolean;
+ interactive: boolean;
+ contents: string | JSX.Element;
+ dialogueBoxStyle?: React.CSSProperties;
+ overlayStyle?: React.CSSProperties;
+ dialogueBoxDisplayedOpacity?: number;
+ overlayDisplayedOpacity?: number;
+}
+
+export default class MainViewModal extends React.Component<MainViewOverlayProps> {
+
+ render() {
+ let p = this.props;
+ let dialogueOpacity = p.dialogueBoxDisplayedOpacity || 1;
+ let overlayOpacity = p.overlayDisplayedOpacity || 0.4;
+ return (
+ <div style={{ pointerEvents: p.isDisplayed ? p.interactive ? "all" : "none" : "none" }}>
+ <div
+ className={"dialogue-box"}
+ style={{
+ backgroundColor: "gainsboro",
+ borderColor: "black",
+ ...(p.dialogueBoxStyle || {}),
+ opacity: p.isDisplayed ? dialogueOpacity : 0
+ }}
+ >{p.contents}</div>
+ <div
+ className={"overlay"}
+ style={{
+ backgroundColor: "gainsboro",
+ ...(p.overlayStyle || {}),
+ opacity: p.isDisplayed ? overlayOpacity : 0
+ }}
+ />
+ </div>
+ );
+ }
+
+
+} \ No newline at end of file
diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx
index 45e0a3562..bb6dd5076 100644
--- a/src/client/views/OverlayView.tsx
+++ b/src/client/views/OverlayView.tsx
@@ -141,6 +141,9 @@ export class OverlayView extends React.Component {
}
@computed get overlayDocs() {
+ if (!CurrentUserUtils.UserDocument) {
+ return (null);
+ }
return CurrentUserUtils.UserDocument.overlays instanceof Doc && DocListCast(CurrentUserUtils.UserDocument.overlays.data).map(d => {
let offsetx = 0, offsety = 0;
let onPointerMove = action((e: PointerEvent) => {
diff --git a/src/client/views/ScriptingRepl.scss b/src/client/views/ScriptingRepl.scss
index f1ef64193..778e9c445 100644
--- a/src/client/views/ScriptingRepl.scss
+++ b/src/client/views/ScriptingRepl.scss
@@ -21,6 +21,7 @@
.scriptingRepl-commandsContainer {
flex: 1 1 auto;
overflow-y: scroll;
+ height: 30px;
}
.documentIcon-outerDiv {
diff --git a/src/client/views/collections/CollectionBaseView.scss b/src/client/views/collections/CollectionBaseView.scss
index 583e6f6ca..aff965469 100644
--- a/src/client/views/collections/CollectionBaseView.scss
+++ b/src/client/views/collections/CollectionBaseView.scss
@@ -1,4 +1,5 @@
@import "../globalCssVariables";
+
#collectionBaseView {
border-width: 0;
border-color: $light-color-secondary;
@@ -6,7 +7,20 @@
border-radius: 0 0 $border-radius $border-radius;
box-sizing: border-box;
border-radius: inherit;
- width:100%;
- height:100%;
+ width: 100%;
+ height: 100%;
overflow: auto;
+}
+
+#google-tags {
+ transition: all 0.5s ease 0s;
+ width: 30px;
+ height: 30px;
+ position: absolute;
+ bottom: 15px;
+ left: 15px;
+ border: 2px solid black;
+ border-radius: 50%;
+ padding: 3px;
+ background: white;
} \ No newline at end of file
diff --git a/src/client/views/collections/CollectionBaseView.tsx b/src/client/views/collections/CollectionBaseView.tsx
index 0168c466f..e928887e2 100644
--- a/src/client/views/collections/CollectionBaseView.tsx
+++ b/src/client/views/collections/CollectionBaseView.tsx
@@ -1,7 +1,7 @@
import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-import { Doc } from '../../../new_fields/Doc';
+import { Doc, DocListCast } from '../../../new_fields/Doc';
import { Id } from '../../../new_fields/FieldSymbols';
import { List } from '../../../new_fields/List';
import { listSpec } from '../../../new_fields/Schema';
@@ -12,6 +12,7 @@ import { ContextMenu } from '../ContextMenu';
import { FieldViewProps } from '../nodes/FieldView';
import './CollectionBaseView.scss';
import { DateField } from '../../../new_fields/DateField';
+import { ImageField } from '../../../new_fields/URLField';
export enum CollectionViewType {
Invalid,
@@ -154,6 +155,21 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> {
return this.removeDocument(doc) ? addDocument(doc) : false;
}
+ showIsTagged = () => {
+ const children = DocListCast(this.props.Document.data);
+ const imageProtos = children.filter(doc => Cast(doc.data, ImageField)).map(Doc.GetProto);
+ const allTagged = imageProtos.length > 0 && imageProtos.every(image => image.googlePhotosTags);
+ if (allTagged) {
+ return (
+ <img
+ id={"google-tags"}
+ src={"/assets/google_tags.png"}
+ />
+ );
+ }
+ return (null);
+ }
+
render() {
const props: CollectionRenderProps = {
addDocument: this.addDocument,
@@ -171,6 +187,7 @@ export class CollectionBaseView extends React.Component<CollectionViewProps> {
}}
className={this.props.className || "collectionView-cont"}
onContextMenu={this.props.onContextMenu} ref={this.props.contentRef}>
+ {this.showIsTagged()}
{viewtype !== undefined ? this.props.children(viewtype, props) : (null)}
</div>
);
diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx
index b047e77a8..963851a12 100644
--- a/src/client/views/collections/CollectionDockingView.tsx
+++ b/src/client/views/collections/CollectionDockingView.tsx
@@ -3,13 +3,13 @@ import { faFile } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import 'golden-layout/src/css/goldenlayout-base.css';
import 'golden-layout/src/css/goldenlayout-dark-theme.css';
-import { action, computed, Lambda, observable, reaction } from "mobx";
+import { action, Lambda, observable, reaction, computed, runInAction } from "mobx";
import { observer } from "mobx-react";
import * as ReactDOM from 'react-dom';
import Measure from "react-measure";
import * as GoldenLayout from "../../../client/goldenLayout";
import { DateField } from '../../../new_fields/DateField';
-import { Doc, DocListCast, Field, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc";
+import { Doc, DocListCast, Field, Opt } from "../../../new_fields/Doc";
import { Id } from '../../../new_fields/FieldSymbols';
import { List } from '../../../new_fields/List';
import { FieldId } from "../../../new_fields/RefField";
@@ -36,7 +36,7 @@ const _global = (window /* browser */ || global /* node */) as any;
@observer
export class CollectionDockingView extends React.Component<SubCollectionViewProps> {
- public static Instance: CollectionDockingView;
+ @observable public static Instance: CollectionDockingView;
public static makeDocumentConfig(document: Doc, dataDoc: Doc | undefined, width?: number) {
return {
type: 'react-component',
@@ -51,7 +51,11 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
};
}
- private _goldenLayout: any = null;
+ @computed public get initialized() {
+ return this._goldenLayout !== null;
+ }
+
+ @observable private _goldenLayout: any = null;
private _containerRef = React.createRef<HTMLDivElement>();
private _flush: boolean = false;
private _ignoreStateChange = "";
@@ -60,7 +64,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
constructor(props: SubCollectionViewProps) {
super(props);
- !CollectionDockingView.Instance && (CollectionDockingView.Instance = this);
+ !CollectionDockingView.Instance && runInAction(() => CollectionDockingView.Instance = this);
//Why is this here?
(window as any).React = React;
(window as any).ReactDOM = ReactDOM;
@@ -251,7 +255,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
var config = StrCast(this.props.Document.dockingConfig);
if (config) {
if (!this._goldenLayout) {
- this._goldenLayout = new GoldenLayout(JSON.parse(config));
+ runInAction(() => this._goldenLayout = new GoldenLayout(JSON.parse(config)));
}
else {
if (config === JSON.stringify(this._goldenLayout.toConfig())) {
@@ -264,7 +268,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
this._goldenLayout.unbind('stackCreated', this.stackCreated);
} catch (e) { }
this._goldenLayout.destroy();
- this._goldenLayout = new GoldenLayout(JSON.parse(config));
+ runInAction(() => this._goldenLayout = new GoldenLayout(JSON.parse(config)));
}
this._goldenLayout.on('itemDropped', this.itemDropped);
this._goldenLayout.on('tabCreated', this.tabCreated);
@@ -292,7 +296,8 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
// Because this is in a set timeout, if this component unmounts right after mounting,
// we will leak a GoldenLayout, because we try to destroy it before we ever create it
setTimeout(() => this.setupGoldenLayout(), 1);
- DocListCast((CurrentUserUtils.UserDocument.workspaces as Doc).data).map(d => d.workspaceBrush = false);
+ let userDoc = CurrentUserUtils.UserDocument;
+ userDoc && DocListCast((userDoc.workspaces as Doc).data).map(d => d.workspaceBrush = false);
this.props.Document.workspaceBrush = true;
}
this._ignoreStateChange = "";
@@ -312,7 +317,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
}
if (this._goldenLayout) this._goldenLayout.destroy();
- this._goldenLayout = null;
+ runInAction(() => this._goldenLayout = null);
window.removeEventListener('resize', this.onResize);
if (this.reactionDisposer) {
@@ -453,8 +458,9 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
let theDoc = doc;
CollectionDockingView.Instance._removedDocs.push(theDoc);
- const recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc);
- if (recent) {
+ let userDoc = CurrentUserUtils.UserDocument;
+ let recent: Doc | undefined;
+ if (userDoc && (recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))) {
Doc.AddDocToList(recent, "data", doc, undefined, true, true);
}
SelectionManager.DeselectAll();
@@ -486,12 +492,13 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp
.off('click') //unbind the current click handler
.click(action(async function () {
//if (confirm('really close this?')) {
- const recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc);
+
stack.remove();
stack.contentItems.forEach(async (contentItem: any) => {
let doc = await DocServer.GetRefField(contentItem.config.props.documentId);
if (doc instanceof Doc) {
- if (recent) {
+ let recent: Doc | undefined;
+ if (CurrentUserUtils.UserDocument && (recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))) {
Doc.AddDocToList(recent, "data", doc, undefined, true, true);
}
let theDoc = doc;
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index cdeba6e16..155f2718b 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -23,6 +23,7 @@ import { CollectionVideoView } from "./CollectionVideoView";
import { CollectionView } from "./CollectionView";
import React = require("react");
var path = require('path');
+import { GooglePhotos } from "../../apis/google_docs/GooglePhotosClientUtils";
export interface CollectionViewProps extends FieldViewProps {
addDocument: (document: Doc, allowDuplicates?: boolean) => boolean;
@@ -152,7 +153,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) {
@undoBatch
@action
- protected onDrop(e: React.DragEvent, options: DocumentOptions, completed?: () => void) {
+ protected async onDrop(e: React.DragEvent, options: DocumentOptions, completed?: () => void) {
if (e.ctrlKey) {
e.stopPropagation(); // bcz: this is a hack to stop propagation when dropping an image on a text document with shift+ctrl
return;
@@ -220,13 +221,22 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) {
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];
+ const documentId = matches[2];
+ proto[GoogleRef] = documentId;
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);
+ // const parent = Docs.Create.StackingDocument([newBox], { title: `Google Doc Import (${documentId})` });
+ // CollectionDockingView.Instance.AddRightSplit(parent, undefined);
+ // proto.height = parent[HeightSym]();
return;
}
+ if ((matches = /(https:\/\/)?photos\.google\.com\/(u\/3\/)?album\/([^\\]+)/g.exec(text)) !== null) {
+ const albums = await GooglePhotos.Transactions.ListAlbums();
+ const albumId = matches[3];
+ const mediaItems = await GooglePhotos.Query.AlbumSearch(albumId);
+ console.log(mediaItems);
+ }
let batch = UndoManager.StartBatch("collection view drop");
let promises: Promise<void>[] = [];
// tslint:disable-next-line:prefer-for-of
@@ -262,7 +272,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) {
}).then(async (res: Response) => {
(await res.json()).map(action((file: any) => {
let full = { ...options, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300, width: 300, title: dropFileName };
- let pathname = Utils.prepend(file);
+ let pathname = Utils.prepend(file.path);
Docs.Get.DocumentFromType(type, pathname, full).then(doc => {
doc && (doc.fileUpload = path.basename(pathname).replace("upload_", "").replace(/\.[a-z0-9]*$/, ""));
doc && this.props.addDocument(doc);
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index d3072ff1e..90fa00202 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -4,9 +4,9 @@ import { faColumns, faEllipsisV, faFingerprint, faImage, faProjectDiagram, faSig
import { action, IReactionDisposer, observable, reaction, runInAction } from 'mobx';
import { observer } from "mobx-react";
import * as React from 'react';
-import { Doc } from '../../../new_fields/Doc';
+import { Doc, DocListCastAsync } from '../../../new_fields/Doc';
import { Id } from '../../../new_fields/FieldSymbols';
-import { StrCast } from '../../../new_fields/Types';
+import { StrCast, Cast } from '../../../new_fields/Types';
import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils';
import { ContextMenu } from "../ContextMenu";
import { ContextMenuProps } from '../ContextMenuItem';
@@ -18,6 +18,7 @@ import { CollectionSchemaView } from "./CollectionSchemaView";
import { CollectionStackingView } from './CollectionStackingView';
import { CollectionTreeView } from "./CollectionTreeView";
import { CollectionViewBaseChrome } from './CollectionViewChromes';
+import { ImageField } from '../../../new_fields/URLField';
import { AddCustomFreeFormLayout } from './collectionFreeForm/CollectionFreeFormLayoutEngines';
export const COLLECTION_BORDER_WIDTH = 2;
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 4b260d111..a9699d17b 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -1,6 +1,6 @@
import { library } from "@fortawesome/fontawesome-svg-core";
import { faEye } from "@fortawesome/free-regular-svg-icons";
-import { faBraille, faChalkboard, faCompass, faCompressArrowsAlt, faExpandArrowsAlt, faPaintBrush, faTable, faUpload } from "@fortawesome/free-solid-svg-icons";
+import { faBraille, faChalkboard, faCompass, faCompressArrowsAlt, faExpandArrowsAlt, faPaintBrush, faTable, faUpload, faFileUpload } from "@fortawesome/free-solid-svg-icons";
import { action, computed, observable } from "mobx";
import { observer } from "mobx-react";
import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../../new_fields/Doc";
@@ -39,7 +39,7 @@ import "./CollectionFreeFormView.scss";
import { MarqueeView } from "./MarqueeView";
import React = require("react");
-library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard);
+library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload);
export const panZoomSchema = createSchema({
panX: "number",
@@ -76,7 +76,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
private panY = () => this.fitToContent ? (this.contentBounds.y + this.contentBounds.b) / 2 : this.Document.panY || 0;
private zoomScaling = () => (1 / this.parentScaling) * (this.fitToContent ?
Math.min(this.props.PanelHeight() / (this.contentBounds.b - this.contentBounds.y), this.props.PanelWidth() / (this.contentBounds.r - this.contentBounds.x)) :
- this.Document.scale || 1);
+ this.Document.scale || 1)
private centeringShiftX = () => !this.nativeWidth && !this.isAnnotationOverlay ? this.props.PanelWidth() / 2 / this.parentScaling : 0; // shift so pan position is at center of window for non-overlay collections
private centeringShiftY = () => !this.nativeHeight && !this.isAnnotationOverlay ? this.props.PanelHeight() / 2 / this.parentScaling : 0;// shift so pan position is at center of window for non-overlay collections
private getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth + 1, -this.borderWidth + 1).translate(-this.centeringShiftX(), -this.centeringShiftY()).transform(this.getLocalTransform());
@@ -116,9 +116,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
}
@action
- onDrop = (e: React.DragEvent): void => {
+ onDrop = (e: React.DragEvent): Promise<void> => {
var pt = this.getTransform().transformPoint(e.pageX, e.pageY);
- super.onDrop(e, { x: pt[0], y: pt[1] });
+ return super.onDrop(e, { x: pt[0], y: pt[1] });
}
@undoBatch
@@ -700,7 +700,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) {
this.props.Document.fitY = this.contentBounds && this.contentBounds.y;
this.props.Document.fitW = this.contentBounds && (this.contentBounds.r - this.contentBounds.x);
this.props.Document.fitH = this.contentBounds && (this.contentBounds.b - this.contentBounds.y);
- // if fieldExt is set, then children will be stored in the extension document for the fieldKey.
+ // if fieldExt is set, then children will be stored in the extension document for the fieldKey.
// otherwise, they are stored in fieldKey. All annotations to this document are stored in the extension document
Doc.UpdateDocumentExtensionForField(this.props.DataDoc || this.props.Document, this.props.fieldKey);
return (
diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.scss b/src/client/views/nodes/CollectionFreeFormDocumentView.scss
index de0b00a81..c0d9e1267 100644
--- a/src/client/views/nodes/CollectionFreeFormDocumentView.scss
+++ b/src/client/views/nodes/CollectionFreeFormDocumentView.scss
@@ -1,5 +1,5 @@
-.collectionFreeFormDocumentView-container {
- transform-origin: left top;
- position: absolute;
- background-color: transparent;
+.collectionFreeFormDocumentView-container {
+ transform-origin: left top;
+ position: absolute;
+ background-color: transparent;
} \ No newline at end of file
diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss
index 63011f271..4ea200e6d 100644
--- a/src/client/views/nodes/DocumentView.scss
+++ b/src/client/views/nodes/DocumentView.scss
@@ -29,6 +29,21 @@
overflow-y: scroll;
height: calc(100% - 20px);
}
+
+ .documentView-overlays {
+ border-radius: inherit;
+ position: absolute;
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ .documentView-textOverlay {
+ border-radius: inherit;
+ width: 100%;
+ display: inline-block;
+ position: absolute;
+ }
+ }
}
.documentView-node-topmost {
background: white;
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index bd702c6a2..779e701ea 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -4,18 +4,14 @@ import { action, computed, runInAction } from "mobx";
import { observer } from "mobx-react";
import * as rp from "request-promise";
import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc";
-import { Copy, Id } from '../../../new_fields/FieldSymbols';
-import { List } from "../../../new_fields/List";
-import { ObjectField } from "../../../new_fields/ObjectField";
+import { Id } from '../../../new_fields/FieldSymbols';
import { createSchema, listSpec, makeInterface } from "../../../new_fields/Schema";
import { ScriptField } from '../../../new_fields/ScriptField';
-import { BoolCast, Cast, FieldValue, NumCast, PromiseValue, StrCast } from "../../../new_fields/Types";
+import { BoolCast, Cast, NumCast, PromiseValue, StrCast } from "../../../new_fields/Types";
import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils";
-import { RouteStore } from '../../../server/RouteStore';
import { emptyFunction, returnTrue, Utils } from "../../../Utils";
import { DocServer } from "../../DocServer";
import { Docs, DocUtils } from "../../documents/Documents";
-import { DocumentType } from '../../documents/DocumentTypes';
import { ClientUtils } from '../../util/ClientUtils';
import { DictationManager } from '../../util/DictationManager';
import { DocumentManager } from "../../util/DocumentManager";
@@ -40,8 +36,11 @@ import { DocumentContentsView } from "./DocumentContentsView";
import "./DocumentView.scss";
import { FormattedTextBox } from './FormattedTextBox';
import React = require("react");
+import { DocumentType } from '../../documents/DocumentTypes';
+import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils';
+import { ImageField } from '../../../new_fields/URLField';
+import SharingManager from '../../util/SharingManager';
import { Scripting } from '../../util/Scripting';
-const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this?
library.add(fa.faTrash);
library.add(fa.faShare);
@@ -181,7 +180,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
if (!e.nativeEvent.cancelBubble && !this.Document.ignoreClick && CurrentUserUtils.MainDocId !== this.props.Document[Id] &&
(Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD)) {
e.stopPropagation();
- e.preventDefault();
+ let preventDefault = true;
if (this._doubleTap && this.props.renderDepth) {
let fullScreenAlias = Doc.MakeAlias(this.props.Document);
let layoutNative = await PromiseValue(Cast(this.props.Document.layoutNative, Doc));
@@ -196,7 +195,11 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
} else if (this.Document.isButton) {
SelectionManager.SelectDoc(this, e.ctrlKey); // don't think this should happen if a button action is actually triggered.
this.buttonClick(e.altKey, e.ctrlKey);
- } else SelectionManager.SelectDoc(this, e.ctrlKey);
+ } else {
+ SelectionManager.SelectDoc(this, e.ctrlKey);
+ preventDefault = false;
+ }
+ preventDefault && e.preventDefault();
}
}
@@ -290,31 +293,46 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
deleteClicked = (): void => { SelectionManager.DeselectAll(); this.props.removeDocument && this.props.removeDocument(this.props.Document); }
@undoBatch
- makeNativeViewClicked = (): void => { swapViews(this.props.Document, "layoutNative", "layoutCustom"); }
-
- @undoBatch
- makeCustomViewClicked = async () => {
- if (this.props.Document.layoutCustom === undefined) {
- Doc.GetProto(this.props.DataDoc || this.props.Document).layoutNative = Doc.MakeTitled("layoutNative");
- await swapViews(this.props.Document, "", "layoutNative");
-
- let options = { title: "data", width: (this.Document.width || 0), x: -(this.Document.width || 0) / 2, y: - (this.Document.height || 0) / 2, };
- let fieldTemplate = this.Document.type === DocumentType.TEXT ? Docs.Create.TextDocument(options) : this.Document.type === DocumentType.PDF ? Docs.Create.PdfDocument("http://www.msn.com", options) :
- this.Document.type === DocumentType.VID ? Docs.Create.VideoDocument("http://www.cs.brown.edu", options) :
- Docs.Create.ImageDocument("http://www.cs.brown.edu", options);
+ static makeNativeViewClicked = async (doc: Doc): Promise<void> => swapViews(doc, "layoutNative", "layoutCustom")
+
+ static makeCustomViewClicked = async (doc: Doc, dataDoc: Opt<Doc>) => {
+ const batch = UndoManager.StartBatch("CustomViewClicked");
+ if (doc.layoutCustom === undefined) {
+ Doc.GetProto(dataDoc || doc).layoutNative = Doc.MakeTitled("layoutNative");
+ await swapViews(doc, "", "layoutNative");
+
+ const width = NumCast(doc.width);
+ const height = NumCast(doc.height);
+ const options = { title: "data", width, x: -width / 2, y: - height / 2, };
+
+ let fieldTemplate: Doc;
+ switch (doc.type) {
+ case DocumentType.TEXT:
+ fieldTemplate = Docs.Create.TextDocument(options);
+ break;
+ case DocumentType.PDF:
+ fieldTemplate = Docs.Create.PdfDocument("http://www.msn.com", options);
+ break;
+ case DocumentType.VID:
+ fieldTemplate = Docs.Create.VideoDocument("http://www.cs.brown.edu", options);
+ break;
+ default:
+ fieldTemplate = Docs.Create.ImageDocument("http://www.cs.brown.edu", options);
+ }
- fieldTemplate.backgroundColor = this.Document.backgroundColor;
+ fieldTemplate.backgroundColor = doc.backgroundColor;
fieldTemplate.heading = 1;
fieldTemplate.autoHeight = true;
- let docTemplate = Docs.Create.FreeformDocument([fieldTemplate], { title: this.Document.title + "_layout", width: (this.Document.width || 0) + 20, height: Math.max(100, (this.Document.height || 0) + 45) });
+ let docTemplate = Docs.Create.FreeformDocument([fieldTemplate], { title: doc.title + "_layout", width: width + 20, height: Math.max(100, height + 45) });
Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate), true);
- Doc.ApplyTemplateTo(docTemplate, this.props.Document, undefined);
- Doc.GetProto(this.props.DataDoc || this.props.Document).layoutCustom = Doc.MakeTitled("layoutCustom");
+ Doc.ApplyTemplateTo(docTemplate, doc, undefined);
+ Doc.GetProto(dataDoc || doc).layoutCustom = Doc.MakeTitled("layoutCustom");
} else {
- swapViews(this.props.Document, "layoutCustom", "layoutNative");
+ await swapViews(doc, "layoutCustom", "layoutNative");
}
+ batch.end();
}
@undoBatch
@@ -401,7 +419,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
if (this.props.ContainingCollectionView && this.props.ContainingCollectionView.props.DataDoc) {
Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.ContainingCollectionView.props.DataDoc);
} else { // bcz: not robust -- for now documents with string layout are native documents, and those with Doc layouts are customized
- custom ? this.makeCustomViewClicked() : this.makeNativeViewClicked();
+ custom ? DocumentView.makeCustomViewClicked(this.props.Document, this.props.DataDoc) : DocumentView.makeNativeViewClicked(this.props.Document);
}
}
@@ -451,6 +469,15 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
subitems.push({ description: "Open Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { width: 300, height: 300 }), undefined, "onRight"), icon: "layer-group" });
cm.addItem({ description: "Open...", subitems: subitems, icon: "external-link-alt" });
+ if (Cast(this.props.Document.data, ImageField)) {
+ cm.addItem({ description: "Export to Google Photos", event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: "caret-square-right" });
+ }
+ if (Cast(Doc.GetProto(this.props.Document).data, listSpec(Doc))) {
+ cm.addItem({ description: "Export to Google Photos Album", event: () => GooglePhotos.Export.CollectionToAlbum({ collection: this.props.Document }).then(console.log), icon: "caret-square-right" });
+ cm.addItem({ description: "Tag Child Images via Google Photos", event: () => GooglePhotos.Query.TagChildImages(this.props.Document), icon: "caret-square-right" });
+ cm.addItem({ description: "Write Back Link to Album", event: () => GooglePhotos.Transactions.AddTextEnrichment(this.props.Document), icon: "caret-square-right" });
+ }
+
let existingOnClick = ContextMenu.Instance.findByDescription("OnClick...");
let onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : [];
onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" });
@@ -479,16 +506,15 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
layoutItems.push({ description: "Center View", event: () => this.props.focus(this.props.Document, false), icon: "crosshairs" });
layoutItems.push({ description: "Zoom to Document", event: () => this.props.focus(this.props.Document, true), icon: "search" });
if (this.Document.type !== DocumentType.COL && this.Document.type !== DocumentType.TEMPLATE) {
- layoutItems.push({ description: "Use Custom Layout", event: this.makeCustomViewClicked, icon: "concierge-bell" });
+ layoutItems.push({ description: "Use Custom Layout", event: () => DocumentView.makeCustomViewClicked(this.props.Document, this.props.DataDoc), icon: "concierge-bell" });
} else if (this.props.Document.layoutNative) {
- layoutItems.push({ description: "Use Native Layout", event: this.makeNativeViewClicked, icon: "concierge-bell" });
+ layoutItems.push({ description: "Use Native Layout", event: () => DocumentView.makeNativeViewClicked(this.props.Document), icon: "concierge-bell" });
}
!existing && cm.addItem({ description: "Layout...", subitems: layoutItems, icon: "compass" });
if (!ClientUtils.RELEASE) {
- let copies: ContextMenuProps[] = [];
- copies.push({ description: "Copy URL", event: () => Utils.CopyText(Utils.prepend("/doc/" + this.props.Document[Id])), icon: "link" });
- copies.push({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document[Id]), icon: "fingerprint" });
- cm.addItem({ description: "Copy...", subitems: copies, icon: "copy" });
+ // let copies: ContextMenuProps[] = [];
+ cm.addItem({ description: "Copy ID", event: () => Utils.CopyText(this.props.Document[Id]), icon: "fingerprint" });
+ // cm.addItem({ description: "Copy...", subitems: copies, icon: "copy" });
}
let existingAnalyze = ContextMenu.Instance.findByDescription("Analyzers...");
let analyzers: ContextMenuProps[] = existingAnalyze && "subitems" in existingAnalyze ? existingAnalyze.subitems : [];
@@ -510,32 +536,6 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
cm.addItem({ description: "Publish", event: () => DocUtils.Publish(this.props.Document, this.Document.title || "", this.props.addDocument, this.props.removeDocument), icon: "file" });
cm.addItem({ description: "Delete", event: this.deleteClicked, icon: "trash" });
- try {
- type User = { email: string, userDocumentId: string };
- const users: User[] = JSON.parse(await rp.get(Utils.prepend(RouteStore.getUsers)));
- let usersMenu = users.filter(({ email }) => email !== Doc.CurrentUserEmail).map(({ email, userDocumentId }) => ({
- description: email,
- event: async () => {
- const userDocument = await Cast(DocServer.GetRefField(userDocumentId), Doc);
- if (userDocument) {
- const notifDoc = await Cast(userDocument.optionalRightCollection, Doc);
- if (notifDoc) {
- const data = await Cast(notifDoc.data, listSpec(Doc));
- const sharedDoc = Doc.MakeAlias(this.props.Document);
- if (data) {
- data.push(sharedDoc);
- } else {
- notifDoc.data = new List([sharedDoc]);
- }
- }
- }
- },
- icon: "male"
- } as ContextMenuProps));
- cm.addItem({ description: "Share...", subitems: usersMenu, icon: "share" });
- } catch {
-
- }
runInAction(() => {
if (!ClientUtils.RELEASE) {
let setWriteMode = (mode: DocServer.WriteMode) => {
@@ -560,6 +560,13 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
cm.addItem({ description: "Collaboration ACLs...", subitems: aclsMenu, icon: "share" });
cm.addItem({ description: "Undo Debug Test", event: () => UndoManager.TraceOpenBatches(), icon: "exclamation" });
}
+ });
+ runInAction(() => {
+ cm.addItem({
+ description: "Share",
+ event: () => SharingManager.Instance.open(this),
+ icon: "external-link-alt"
+ });
if (!this.topMost) {
// DocumentViews should stop propagation of this event
diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx
index 63a16f90c..db5814e7c 100644
--- a/src/client/views/nodes/FormattedTextBox.tsx
+++ b/src/client/views/nodes/FormattedTextBox.tsx
@@ -12,7 +12,7 @@ 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, ToPlainText, FromPlainText } from "../../../new_fields/RichTextField";
+import { RichTextField } from "../../../new_fields/RichTextField";
import { BoolCast, Cast, NumCast, StrCast, DateCast, PromiseValue } from "../../../new_fields/Types";
import { createSchema, makeInterface } from "../../../new_fields/Schema";
import { Utils, numberRange, timenow } from '../../../Utils';
@@ -37,6 +37,8 @@ import { DocumentDecorations } from '../DocumentDecorations';
import { DictationManager } from '../../util/DictationManager';
import { ReplaceStep } from 'prosemirror-transform';
import { DocumentType } from '../../documents/DocumentTypes';
+import { RichTextUtils } from '../../../new_fields/RichTextUtils';
+import _ from "lodash";
import { formattedTextBoxCommentPlugin, FormattedTextBoxComment } from './FormattedTextBoxComment';
import { inputRules } from 'prosemirror-inputrules';
import { DocumentButtonBar } from '../DocumentButtonBar';
@@ -44,8 +46,6 @@ import { DocumentButtonBar } from '../DocumentButtonBar';
library.add(faEdit);
library.add(faSmile, faTextHeight, faUpload);
-export const Blank = `{"doc":{"type":"doc","content":[]},"selection":{"type":"text","anchor":0,"head":0}}`;
-
export interface FormattedTextBoxProps {
isOverlay?: boolean;
hideOnLeave?: boolean;
@@ -64,13 +64,15 @@ export const GoogleRef = "googleDocId";
type RichTextDocument = makeInterface<[typeof richTextSchema]>;
const RichTextDocument = makeInterface(richTextSchema);
-type PullHandler = (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => void;
+type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void;
@observer
export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) {
public static LayoutString(fieldStr: string = "data") {
return FieldView.LayoutString(FormattedTextBox, fieldStr);
}
+ public static blankState = () => EditorState.create(FormattedTextBox.Instance.config);
+ public static Instance: FormattedTextBox;
private static _toolTipTextMenu: TooltipTextMenu | undefined = undefined;
private _ref: React.RefObject<HTMLDivElement> = React.createRef();
private _proseRef?: HTMLDivElement;
@@ -139,7 +141,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
if (this.props.isOverlay) {
DragManager.StartDragFunctions.push(() => FormattedTextBox.InputBoxOverlay = undefined);
}
-
+ FormattedTextBox.Instance = this;
this._scrollToRegionReactionDisposer = reaction(
() => StrCast(this.props.Document.scrollToLinkID),
async (scrollToLinkID) => {
@@ -252,8 +254,8 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
this.linkOnDeselect.set(key, value);
let id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key);
- const link = this._editorView!.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value });
- const mval = this._editorView!.state.schema.marks.metadataVal.create();
+ const link = this._editorView.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value });
+ const mval = this._editorView.state.schema.marks.metadataVal.create();
let offset = (tx.selection.to === range!.end - 1 ? -1 : 0);
tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval);
this.dataDoc[key] = value;
@@ -337,10 +339,11 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
let target = de.data.embeddableSourceDoc;
// We're dealing with an internal document drop
let url = de.data.urlField.url.href;
- let model: NodeType = (url.includes(".mov") || url.includes(".mp4")) ? schema.nodes.video : schema.nodes.image;
+ let model: NodeType = [".mov", ".mp4"].includes(url) ? schema.nodes.video : schema.nodes.image;
let pos = this._editorView!.posAtCoords({ left: de.x, top: de.y });
this._editorView!.dispatch(this._editorView!.state.tr.insert(pos!.pos, model.create({ src: url, docid: target[Id] })));
DocUtils.MakeLink(this.dataDoc, target, undefined, "ImgRef:" + target.title, undefined, undefined, target[Id]);
+ this.tryUpdateHeight();
e.stopPropagation();
} else if (de.data instanceof DragManager.DocumentDragData) {
const draggedDoc = de.data.draggedDocuments.length && de.data.draggedDocuments[0];
@@ -462,7 +465,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
this._reactionDisposer = reaction(
() => {
const field = this.dataDoc ? Cast(this.dataDoc[this.props.fieldKey], RichTextField) : undefined;
- return field ? field.Data : Blank;
+ return field ? field.Data : RichTextUtils.Initialize();
},
incomingValue => {
if (this._editorView && !this._applyingChange) {
@@ -565,18 +568,17 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}
pushToGoogleDoc = async () => {
- this.pullFromGoogleDoc(async (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => {
- let modes = GoogleApiClientUtils.WriteMode;
+ this.pullFromGoogleDoc(async (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
+ let modes = GoogleApiClientUtils.Docs.WriteMode;
let mode = modes.Replace;
- let reference: Opt<GoogleApiClientUtils.Reference> = Cast(this.dataDoc[GoogleRef], "string");
+ let reference: Opt<GoogleApiClientUtils.Docs.Reference> = Cast(this.dataDoc[GoogleRef], "string");
if (!reference) {
mode = modes.Insert;
- reference = { service: GoogleApiClientUtils.Service.Documents, title: StrCast(this.dataDoc.title) };
+ reference = { title: StrCast(this.dataDoc.title) };
}
let redo = async () => {
- let data = Cast(this.dataDoc.data, RichTextField);
- if (this._editorView && reference && data) {
- let content = data[ToPlainText]();
+ if (this._editorView && reference) {
+ let content = await RichTextUtils.GoogleDocs.Export(this._editorView.state);
let response = await GoogleApiClientUtils.Docs.write({ reference, content, mode });
response && (this.dataDoc[GoogleRef] = response.documentId);
let pushSuccess = response !== undefined && !("errors" in response);
@@ -585,7 +587,13 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
}
};
let undo = () => {
- let content = exportState.body;
+ if (!exportState) {
+ return;
+ }
+ let content: GoogleApiClientUtils.Docs.Content = {
+ text: exportState.text,
+ requests: []
+ };
if (reference && content) {
GoogleApiClientUtils.Docs.write({ reference, content, mode });
}
@@ -598,49 +606,41 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe
pullFromGoogleDoc = async (handler: PullHandler) => {
let dataDoc = this.dataDoc;
let documentId = StrCast(dataDoc[GoogleRef]);
- let exportState: GoogleApiClientUtils.ReadResult = {};
+ let exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>;
if (documentId) {
- exportState = await GoogleApiClientUtils.Docs.read({ identifier: documentId });
+ exportState = await RichTextUtils.GoogleDocs.Import(documentId, dataDoc);
}
UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls);
}
- updateState = (exportState: GoogleApiClientUtils.ReadResult, dataDoc: Doc) => {
+ updateState = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
let pullSuccess = false;
- if (exportState !== undefined && exportState.body !== undefined && exportState.title !== undefined) {
- const data = Cast(dataDoc.data, RichTextField);
- if (data instanceof RichTextField) {
- pullSuccess = true;
- dataDoc.data = new RichTextField(data[FromPlainText](exportState.body));
- setTimeout(() => {
- if (this._editorView) {
- let state = this._editorView.state;
- let end = state.doc.content.size - 1;
- this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end)));
- }
- }, 0);
- dataDoc.title = exportState.title;
- this.Document.customTitle = true;
- dataDoc.unchanged = true;
- }
+ if (exportState !== undefined) {
+ pullSuccess = true;
+ dataDoc.data = new RichTextField(JSON.stringify(exportState.state.toJSON()));
+ setTimeout(() => {
+ if (this._editorView) {
+ let state = this._editorView.state;
+ let end = state.doc.content.size - 1;
+ this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end)));
+ }
+ }, 0);
+ dataDoc.title = exportState.title;
+ this.Document.customTitle = true;
+ dataDoc.unchanged = true;
} else {
delete dataDoc[GoogleRef];
}
DocumentButtonBar.Instance.startPullOutcome(pullSuccess);
}
- checkState = (exportState: GoogleApiClientUtils.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;
- DocumentButtonBar.Instance.setPullState(unchanged);
- }
+ checkState = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => {
+ if (exportState && this._editorView) {
+ let equalContent = _.isEqual(this._editorView.state.doc, exportState.state.doc);
+ let equalTitles = dataDoc.title === exportState.title;
+ let unchanged = equalContent && equalTitles;
+ dataDoc.unchanged = unchanged;
+ DocumentButtonBar.Instance.setPullState(unchanged);
}
}
diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss
index 00c069e1f..71d718b39 100644
--- a/src/client/views/nodes/ImageBox.scss
+++ b/src/client/views/nodes/ImageBox.scss
@@ -1,43 +1,72 @@
.imageBox-cont {
- padding: 0vw;
- position: relative;
- text-align: center;
- width: 100%;
- height: auto;
- max-width: 100%;
- max-height: 100%;
- pointer-events: none;
+ padding: 0vw;
+ position: relative;
+ text-align: center;
+ width: 100%;
+ height: auto;
+ max-width: 100%;
+ max-height: 100%;
+ pointer-events: none;
}
+
.imageBox-cont-interactive {
- pointer-events: all;
- width:100%;
- height:auto;
+ pointer-events: all;
+ width: 100%;
+ height: auto;
}
.imageBox-dot {
- position:absolute;
+ position: absolute;
bottom: 10;
left: 0;
border-radius: 10px;
- width:20px;
- height:20px;
- background:gray;
+ width: 20px;
+ height: 20px;
+ background: gray;
}
.imageBox-cont img {
height: auto;
- width:100%;
+ width: 100%;
}
+
.imageBox-cont-interactive img {
height: auto;
- width:100%;
+ width: 100%;
+}
+
+#google-photos {
+ transition: all 0.5s ease 0s;
+ width: 30px;
+ height: 30px;
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ border: 2px solid black;
+ border-radius: 50%;
+ padding: 3px;
+ background: white;
+ cursor: pointer;
+}
+
+#google-tags {
+ transition: all 0.5s ease 0s;
+ width: 30px;
+ height: 30px;
+ position: absolute;
+ bottom: 15px;
+ right: 15px;
+ border: 2px solid black;
+ border-radius: 50%;
+ padding: 3px;
+ background: white;
}
.imageBox-button {
- padding: 0vw;
- border: none;
- width: 100%;
- height: 100%;
+ padding: 0vw;
+ border: none;
+ width: 100%;
+ height: 100%;
}
.imageBox-audioBackground {
@@ -49,6 +78,7 @@
border-radius: 25px;
background: white;
opacity: 0.3;
+
svg {
width: 90% !important;
height: 70%;
@@ -59,44 +89,47 @@
}
#cf {
- position:relative;
- width:100%;
- margin:0 auto;
- display:flex;
+ position: relative;
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
align-items: center;
- height:100%;
- overflow:hidden;
+ height: 100%;
+ overflow: hidden;
+
.imageBox-fadeBlocker {
- width:100%;
- height:100%;
+ width: 100%;
+ height: 100%;
background: black;
- display:flex;
+ display: flex;
flex-direction: row;
align-items: center;
z-index: 1;
+
.imageBox-fadeaway {
object-fit: contain;
- width:100%;
- height:100%;
+ width: 100%;
+ height: 100%;
}
}
- }
-
- #cf img {
- position:absolute;
- left:0;
- }
-
- .imageBox-fadeBlocker {
+}
+
+#cf img {
+ position: absolute;
+ left: 0;
+}
+
+.imageBox-fadeBlocker {
-webkit-transition: opacity 1s ease-in-out;
-moz-transition: opacity 1s ease-in-out;
-o-transition: opacity 1s ease-in-out;
transition: opacity 1s ease-in-out;
- }
- .imageBox-fadeBlocker:hover {
+}
+
+.imageBox-fadeBlocker:hover {
-webkit-transition: opacity 1s ease-in-out;
-moz-transition: opacity 1s ease-in-out;
-o-transition: opacity 1s ease-in-out;
transition: opacity 1s ease-in-out;
- opacity:0;
- } \ No newline at end of file
+ opacity: 0;
+} \ No newline at end of file
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index 004f50590..9e9fe1324 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -63,6 +63,7 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
private _lastTap: number = 0;
@observable private _isOpen: boolean = false;
private dropDisposer?: DragManager.DragDropDisposer;
+ @observable private hoverActive = false;
@computed get extensionDoc() { return Doc.fieldExtensionDoc(this.dataDoc, this.props.fieldKey); }
@@ -352,6 +353,33 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
this.recordAudioAnnotation();
}
+ considerGooglePhotosLink = () => {
+ const remoteUrl = StrCast(this.props.Document.googlePhotosUrl);
+ if (remoteUrl) {
+ return (
+ <img
+ id={"google-photos"}
+ src={"/assets/google_photos.png"}
+ style={{ opacity: this.hoverActive ? 1 : 0 }}
+ onClick={() => window.open(remoteUrl)}
+ />
+ );
+ }
+ return (null);
+ }
+
+ considerGooglePhotosTags = () => {
+ const tags = StrCast(this.props.Document.googlePhotosTags);
+ if (tags) {
+ return (
+ <img
+ id={"google-tags"}
+ src={"/assets/google_tags.png"}
+ />
+ );
+ }
+ return (null);
+ }
render() {
// let transform = this.props.ScreenToLocalTransform().inverse();
@@ -387,6 +415,8 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
return (
<div className={`imageBox-cont${interactive}`} style={{ background: "transparent" }}
onPointerDown={this.onPointerDown}
+ onPointerEnter={action(() => this.hoverActive = true)}
+ onPointerLeave={action(() => this.hoverActive = false)}
onDrop={this.onDrop} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}>
<div id="cf">
<img
@@ -413,6 +443,7 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD
<FontAwesomeIcon className="imageBox-audioFont"
style={{ color: [DocListCast(this.extensionDoc.audioAnnotations).length ? "blue" : "gray", "green", "red"][this._audioState] }} icon={faFileAudio} size="sm" />
</div>
+ {this.considerGooglePhotosLink()}
{/* {this.lightbox(paths)} */}
<FaceRectangles document={this.extensionDoc} color={"#0000FF"} backgroundColor={"#0000FF"} />
</div>);
diff --git a/src/client/views/pdf/Page.scss b/src/client/views/pdf/Page.scss
new file mode 100644
index 000000000..d8034b4b4
--- /dev/null
+++ b/src/client/views/pdf/Page.scss
@@ -0,0 +1,36 @@
+
+.pdfViewer-text {
+ .page {
+ position: relative;
+ }
+}
+.pdfPage-cont {
+ position: relative;
+
+ .pdfPage-canvasContainer {
+ position: absolute;
+ }
+
+ .pdfPage-dragAnnotationBox {
+ position: absolute;
+ background-color: transparent;
+ opacity: 0.1;
+ }
+
+ .pdfPage-textLayer {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ div {
+ user-select: text;
+ }
+ span {
+ color: transparent;
+ position: absolute;
+ white-space: pre;
+ cursor: text;
+ -webkit-transform-origin: 0% 0%;
+ transform-origin: 0% 0%;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/client/views/pdf/Page.tsx b/src/client/views/pdf/Page.tsx
new file mode 100644
index 000000000..533247170
--- /dev/null
+++ b/src/client/views/pdf/Page.tsx
@@ -0,0 +1,293 @@
+import { action, IReactionDisposer, observable } from "mobx";
+import { observer } from "mobx-react";
+import * as Pdfjs from "pdfjs-dist";
+import "pdfjs-dist/web/pdf_viewer.css";
+import { Doc, DocListCastAsync, Opt, WidthSym } from "../../../new_fields/Doc";
+import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types";
+import { Docs, DocUtils } from "../../documents/Documents";
+import { DragManager } from "../../util/DragManager";
+import PDFMenu from "./PDFMenu";
+import { scale } from "./PDFViewer";
+import "./Page.scss";
+import React = require("react");
+
+
+interface IPageProps {
+ size: { width: number, height: number };
+ pdf: Pdfjs.PDFDocumentProxy;
+ name: string;
+ numPages: number;
+ page: number;
+ pageLoaded: (page: Pdfjs.PDFPageViewport) => void;
+ fieldExtensionDoc: Doc;
+ Document: Doc;
+ renderAnnotations: (annotations: Doc[], removeOld: boolean) => void;
+ sendAnnotations: (annotations: HTMLDivElement[], page: number) => void;
+ createAnnotation: (div: HTMLDivElement, page: number) => void;
+ makeAnnotationDocuments: (doc: Doc | undefined, color: string, linkTo: boolean) => Doc;
+ getScrollFromPage: (page: number) => number;
+ setSelectionText: (text: string) => void;
+}
+
+@observer
+export default class Page extends React.Component<IPageProps> {
+ @observable private _state: "N/A" | "rendering" = "N/A";
+ @observable private _width: number = this.props.size.width;
+ @observable private _height: number = this.props.size.height;
+ @observable private _page: Opt<Pdfjs.PDFPageProxy>;
+ @observable private _currPage: number = this.props.page + 1;
+ @observable private _marqueeX: number = 0;
+ @observable private _marqueeY: number = 0;
+ @observable private _marqueeWidth: number = 0;
+ @observable private _marqueeHeight: number = 0;
+
+ private _canvas: React.RefObject<HTMLCanvasElement> = React.createRef();
+ private _textLayer: React.RefObject<HTMLDivElement> = React.createRef();
+ private _marquee: React.RefObject<HTMLDivElement> = React.createRef();
+ private _marqueeing: boolean = false;
+ private _reactionDisposer?: IReactionDisposer;
+ private _startY: number = 0;
+ private _startX: number = 0;
+
+ componentDidMount = (): void => this.loadPage(this.props.pdf);
+
+ componentDidUpdate = (): void => this.loadPage(this.props.pdf);
+
+ componentWillUnmount = (): void => this._reactionDisposer && this._reactionDisposer();
+
+ loadPage = (pdf: Pdfjs.PDFDocumentProxy): void => {
+ pdf.getPage(this._currPage).then(page => this.renderPage(page));
+ }
+
+ @action
+ renderPage = (page: Pdfjs.PDFPageProxy): void => {
+ // lower scale = easier to read at small sizes, higher scale = easier to read at large sizes
+ if (this._state !== "rendering" && !this._page && this._canvas.current && this._textLayer.current) {
+ this._state = "rendering";
+ let viewport = page.getViewport(scale);
+ this._canvas.current.width = this._width = viewport.width;
+ this._canvas.current.height = this._height = viewport.height;
+ this.props.pageLoaded(viewport);
+ let ctx = this._canvas.current.getContext("2d");
+ if (ctx) {
+ //@ts-ignore
+ page.render({ canvasContext: ctx, viewport: viewport, enableWebGL: true }); // renders the page onto the canvas context
+ page.getTextContent().then(res => // renders text onto the text container
+ //@ts-ignore
+ Pdfjs.renderTextLayer({
+ textContent: res,
+ container: this._textLayer.current,
+ viewport: viewport
+ }));
+
+ this._page = page;
+ }
+ }
+ }
+
+ @action
+ highlight = (targetDoc: Doc | undefined, color: string) => {
+ // creates annotation documents for current highlights
+ let annotationDoc = this.props.makeAnnotationDocuments(targetDoc, color, false);
+ Doc.AddDocToList(this.props.fieldExtensionDoc, "annotations", annotationDoc);
+ return annotationDoc;
+ }
+
+ /**
+ * This is temporary for creating annotations from highlights. It will
+ * start a drag event and create or put the necessary info into the drag event.
+ */
+ @action
+ startDrag = (e: PointerEvent, ele: HTMLElement): void => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this._textLayer.current) {
+ let targetDoc = Docs.Create.TextDocument({ width: 200, height: 200, title: "New Annotation" });
+ targetDoc.targetPage = this.props.page;
+ let annotationDoc = this.highlight(undefined, "red");
+ annotationDoc.linkedToDoc = false;
+ let dragData = new DragManager.AnnotationDragData(this.props.Document, annotationDoc, targetDoc);
+ DragManager.StartAnnotationDrag([ele], dragData, e.pageX, e.pageY, {
+ handlers: {
+ dragComplete: async () => {
+ if (!BoolCast(annotationDoc.linkedToDoc)) {
+ let annotations = await DocListCastAsync(annotationDoc.annotations);
+ annotations && annotations.forEach(anno => anno.target = targetDoc);
+ DocUtils.MakeLink(annotationDoc, targetDoc, dragData.targetContext, `Annotation from ${StrCast(this.props.Document.title)}`);
+ }
+ }
+ },
+ hideSource: false
+ });
+ }
+ }
+
+ // cleans up events and boolean
+ endDrag = (e: PointerEvent): void => {
+ e.stopPropagation();
+ }
+
+ createSnippet = (marquee: { left: number, top: number, width: number, height: number }): void => {
+ let view = Doc.MakeAlias(this.props.Document);
+ let data = Doc.MakeDelegate(Doc.GetProto(this.props.Document));
+ data.title = StrCast(data.title) + "_snippet";
+ view.proto = data;
+ view.nativeHeight = marquee.height;
+ view.height = (this.props.Document[WidthSym]() / NumCast(this.props.Document.nativeWidth)) * marquee.height;
+ view.nativeWidth = this.props.Document.nativeWidth;
+ view.startY = marquee.top + this.props.getScrollFromPage(this.props.page);
+ view.width = this.props.Document[WidthSym]();
+ DragManager.StartDocumentDrag([], new DragManager.DocumentDragData([view]), 0, 0);
+ }
+
+ @action
+ onPointerDown = (e: React.PointerEvent): void => {
+ // if alt+left click, drag and annotate
+ if (NumCast(this.props.Document.scale, 1) !== 1) return;
+ if (!e.altKey && e.button === 0) {
+ PDFMenu.Instance.StartDrag = this.startDrag;
+ PDFMenu.Instance.Highlight = this.highlight;
+ PDFMenu.Instance.Snippet = this.createSnippet;
+ PDFMenu.Instance.Status = "pdf";
+ PDFMenu.Instance.fadeOut(true);
+ if (e.target && (e.target as any).parentElement === this._textLayer.current) {
+ e.stopPropagation();
+ if (!e.ctrlKey) {
+ this.props.sendAnnotations([], -1);
+ }
+ }
+ else {
+ // set marquee x and y positions to the spatially transformed position
+ if (this._textLayer.current) {
+ let boundingRect = this._textLayer.current.getBoundingClientRect();
+ this._startX = this._marqueeX = (e.clientX - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width);
+ this._startY = this._marqueeY = (e.clientY - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height);
+ }
+ this._marqueeing = true;
+ this._marquee.current && (this._marquee.current.style.opacity = "0.2");
+ this.props.sendAnnotations([], -1);
+ }
+ document.removeEventListener("pointermove", this.onSelectStart);
+ document.addEventListener("pointermove", this.onSelectStart);
+ document.removeEventListener("pointerup", this.onSelectEnd);
+ document.addEventListener("pointerup", this.onSelectEnd);
+ }
+ }
+
+ @action
+ onSelectStart = (e: PointerEvent): void => {
+ if (this._marqueeing && this._textLayer.current) {
+ // transform positions and find the width and height to set the marquee to
+ let boundingRect = this._textLayer.current.getBoundingClientRect();
+ this._marqueeWidth = ((e.clientX - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width)) - this._startX;
+ this._marqueeHeight = ((e.clientY - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height)) - this._startY;
+ this._marqueeX = Math.min(this._startX, this._startX + this._marqueeWidth);
+ this._marqueeY = Math.min(this._startY, this._startY + this._marqueeHeight);
+ this._marqueeWidth = Math.abs(this._marqueeWidth);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ else if (e.target && (e.target as any).parentElement === this._textLayer.current) {
+ e.stopPropagation();
+ }
+ }
+
+ @action
+ onSelectEnd = (e: PointerEvent): void => {
+ if (this._marqueeing) {
+ this._marqueeing = false;
+ if (this._marqueeWidth > 10 || this._marqueeHeight > 10) {
+ if (this._marquee.current) { // make a copy of the marquee
+ let copy = document.createElement("div");
+ let style = this._marquee.current.style;
+ copy.style.left = style.left;
+ copy.style.top = style.top;
+ copy.style.width = style.width;
+ copy.style.height = style.height;
+ copy.style.border = style.border;
+ copy.style.opacity = style.opacity;
+ copy.className = "pdfPage-annotationBox";
+ this.props.createAnnotation(copy, this.props.page);
+ this._marquee.current.style.opacity = "0";
+ }
+
+ if (!e.ctrlKey) {
+ PDFMenu.Instance.Status = "snippet";
+ PDFMenu.Instance.Marquee = { left: this._marqueeX, top: this._marqueeY, width: this._marqueeWidth, height: this._marqueeHeight };
+ }
+ PDFMenu.Instance.jumpTo(e.clientX, e.clientY);
+ }
+
+ this._marqueeHeight = this._marqueeWidth = 0;
+ }
+ else {
+ let sel = window.getSelection();
+ if (sel && sel.type === "Range") {
+ let selRange = sel.getRangeAt(0);
+ this.createTextAnnotation(sel, selRange);
+ PDFMenu.Instance.jumpTo(e.clientX, e.clientY);
+ }
+ }
+
+ if (PDFMenu.Instance.Highlighting) {
+ this.highlight(undefined, "goldenrod");
+ }
+ else {
+ PDFMenu.Instance.StartDrag = this.startDrag;
+ PDFMenu.Instance.Highlight = this.highlight;
+ }
+ document.removeEventListener("pointermove", this.onSelectStart);
+ document.removeEventListener("pointerup", this.onSelectEnd);
+ }
+
+ @action
+ createTextAnnotation = (sel: Selection, selRange: Range) => {
+ if (this._textLayer.current) {
+ let boundingRect = this._textLayer.current.getBoundingClientRect();
+ let clientRects = selRange.getClientRects();
+ for (let i = 0; i < clientRects.length; i++) {
+ let rect = clientRects.item(i);
+ if (rect && rect.width !== this._textLayer.current.getBoundingClientRect().width && rect.height !== this._textLayer.current.getBoundingClientRect().height) {
+ let annoBox = document.createElement("div");
+ annoBox.className = "pdfPage-annotationBox";
+ // transforms the positions from screen onto the pdf div
+ annoBox.style.top = ((rect.top - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height)).toString();
+ annoBox.style.left = ((rect.left - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width)).toString();
+ annoBox.style.width = (rect.width * this._textLayer.current.offsetWidth / boundingRect.width).toString();
+ annoBox.style.height = (rect.height * this._textLayer.current.offsetHeight / boundingRect.height).toString();
+ this.props.createAnnotation(annoBox, this.props.page);
+ }
+ }
+ }
+ let text = selRange.cloneContents().textContent;
+ text && this.props.setSelectionText(text);
+
+ // clear selection
+ if (sel.empty) { // Chrome
+ sel.empty();
+ } else if (sel.removeAllRanges) { // Firefox
+ sel.removeAllRanges();
+ }
+ }
+
+ doubleClick = (e: React.MouseEvent) => {
+ if (e.target && (e.target as any).parentElement === this._textLayer.current) {
+ // do something to select the paragraph ideally
+ }
+ }
+
+ render() {
+ return (
+ <div className={"pdfPage-cont"} onPointerDown={this.onPointerDown} onDoubleClick={this.doubleClick} style={{ "width": this._width, "height": this._height }}>
+ <canvas className="PdfPage-canvasContainer" ref={this._canvas} />
+ <div className="pdfPage-dragAnnotationBox" ref={this._marquee}
+ style={{
+ left: `${this._marqueeX}px`, top: `${this._marqueeY}px`,
+ width: `${this._marqueeWidth}px`, height: `${this._marqueeHeight}px`,
+ border: `${this._marqueeWidth === 0 ? "" : "10px dashed black"}`
+ }}>
+ </div>
+ <div className="pdfPage-textlayer" ref={this._textLayer} />
+ </div>);
+ }
+} \ No newline at end of file
diff --git a/src/client/views/presentationview/PresentationList.tsx b/src/client/views/presentationview/PresentationList.tsx
index 930ce202e..f730b4cf2 100644
--- a/src/client/views/presentationview/PresentationList.tsx
+++ b/src/client/views/presentationview/PresentationList.tsx
@@ -17,7 +17,9 @@ interface PresListProps {
presStatus: boolean;
removeDocByRef(doc: Doc): boolean;
clearElemMap(): void;
-
+ groupMappings: Map<String, Doc[]>;
+ presGroupBackUp: Doc;
+ presButtonBackUp: Doc;
}
diff --git a/src/client/views/presentationview/PresentationView.tsx b/src/client/views/presentationview/PresentationView.tsx
new file mode 100644
index 000000000..54e789a0a
--- /dev/null
+++ b/src/client/views/presentationview/PresentationView.tsx
@@ -0,0 +1,993 @@
+import { observer } from "mobx-react";
+import React = require("react");
+import { observable, action, runInAction, reaction, autorun } from "mobx";
+import "./PresentationView.scss";
+import { DocumentManager } from "../../util/DocumentManager";
+import { Utils } from "../../../Utils";
+import { Doc, DocListCast, DocListCastAsync, WidthSym } from "../../../new_fields/Doc";
+import { listSpec } from "../../../new_fields/Schema";
+import { Cast, NumCast, FieldValue, PromiseValue, StrCast, BoolCast } from "../../../new_fields/Types";
+import { Id } from "../../../new_fields/FieldSymbols";
+import { List } from "../../../new_fields/List";
+import PresentationElement, { buttonIndex } from "./PresentationElement";
+import { library } from '@fortawesome/fontawesome-svg-core';
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faArrowRight, faArrowLeft, faPlay, faStop, faPlus, faTimes, faMinus, faEdit, faEye } from '@fortawesome/free-solid-svg-icons';
+import { Docs } from "../../documents/Documents";
+import { undoBatch, UndoManager } from "../../util/UndoManager";
+import PresentationViewList from "./PresentationList";
+import PresModeMenu from "./PresentationModeMenu";
+import { CollectionDockingView } from "../collections/CollectionDockingView";
+
+library.add(faArrowLeft);
+library.add(faArrowRight);
+library.add(faPlay);
+library.add(faStop);
+library.add(faPlus);
+library.add(faTimes);
+library.add(faMinus);
+library.add(faEdit);
+library.add(faEye);
+
+
+export interface PresViewProps {
+ Documents: List<Doc>;
+}
+
+const expandedWidth = 400;
+const presMinWidth = 300;
+
+@observer
+export class PresentationView extends React.Component<PresViewProps> {
+ public static Instance: PresentationView;
+
+ //Mapping from presentation ids to a list of doc that represent a group
+ @observable groupMappings: Map<String, Doc[]> = new Map();
+ //mapping from docs to their rendered component
+ @observable presElementsMappings: Map<Doc, PresentationElement> = new Map();
+ //variable that holds all the docs in the presentation
+ @observable childrenDocs: Doc[] = [];
+ //variable to hold if presentation is started
+ @observable presStatus: boolean = false;
+ //back-up so that presentation stays the way it's when refreshed
+ @observable presGroupBackUp: Doc = new Doc();
+ @observable presButtonBackUp: Doc = new Doc();
+
+ //Keeping track of the doc for the current presentation
+ @observable curPresentation: Doc = new Doc();
+ //Mapping of guids to presentations.
+ @observable presentationsMapping: Map<String, Doc> = new Map();
+ //Mapping of presentations to guid, so that select option values can be given.
+ @observable presentationsKeyMapping: Map<Doc, String> = new Map();
+ //Variable to keep track of guid of the current presentation
+ @observable currentSelectedPresValue: string | undefined;
+ //A flag to keep track if title input is open, which is used in rendering.
+ @observable PresTitleInputOpen: boolean = false;
+ //Variable that holds reference to title input, so that new presentations get titles assigned.
+ @observable titleInputElement: HTMLInputElement | undefined;
+ @observable PresTitleChangeOpen: boolean = false;
+ @observable presMode: boolean = false;
+
+
+ @observable opacity = 1;
+ @observable persistOpacity = true;
+ @observable labelOpacity = 0;
+
+ //initilize class variables
+ constructor(props: PresViewProps) {
+ super(props);
+ PresentationView.Instance = this;
+ }
+
+ @action
+ toggle = (forcedValue: boolean | undefined) => {
+ if (forcedValue !== undefined) {
+ this.curPresentation.width = forcedValue ? expandedWidth : 0;
+ } else {
+ this.curPresentation.width = this.curPresentation.width === expandedWidth ? 0 : expandedWidth;
+ }
+ }
+
+ //The first lifecycle function that gets called to set up the current presentation.
+ async componentWillMount() {
+
+ this.props.Documents.forEach(async (doc, index: number) => {
+
+ //For each presentation received from mainContainer, a mapping is created.
+ let curDoc: Doc = await doc;
+ let newGuid = Utils.GenerateGuid();
+ this.presentationsKeyMapping.set(curDoc, newGuid);
+ this.presentationsMapping.set(newGuid, curDoc);
+
+ //The Presentation at first index gets set as default start presentation
+ if (index === 0) {
+ runInAction(() => this.currentSelectedPresValue = newGuid);
+ runInAction(() => this.curPresentation = curDoc);
+ }
+ });
+ }
+
+ //Second lifecycle function that gets called when component mounts. It makes sure to
+ //get the back-up information from previous session for the current presentation.
+ async componentDidMount() {
+ let docAtZero = await this.props.Documents[0];
+ runInAction(() => this.curPresentation = docAtZero);
+ this.curPresentation.width = 0;
+ this.setPresentationBackUps();
+ }
+
+
+ /**
+ * The function that retrieves the backUps for the current Presentation if present,
+ * otherwise initializes.
+ */
+ setPresentationBackUps = async () => {
+ //getting both backUp documents
+
+ let castedGroupBackUp = Cast(this.curPresentation.presGroupBackUp, Doc);
+ let castedButtonBackUp = Cast(this.curPresentation.presButtonBackUp, Doc);
+ //if instantiated before
+ if (castedGroupBackUp instanceof Promise) {
+ castedGroupBackUp.then(doc => {
+ let toAssign = doc ? doc : new Doc();
+ this.curPresentation.presGroupBackUp = toAssign;
+ runInAction(() => this.presGroupBackUp = toAssign);
+ if (doc) {
+ if (toAssign[Id] === doc[Id]) {
+ this.retrieveGroupMappings();
+ }
+ }
+ });
+
+ //if never instantiated a store doc yet
+ } else if (castedGroupBackUp instanceof Doc) {
+ let castedDoc: Doc = await castedGroupBackUp;
+ runInAction(() => this.presGroupBackUp = castedDoc);
+ this.retrieveGroupMappings();
+ } else {
+ runInAction(() => {
+ let toAssign = new Doc();
+ this.presGroupBackUp = toAssign;
+ this.curPresentation.presGroupBackUp = toAssign;
+
+ });
+
+ }
+ //if instantiated before
+ if (castedButtonBackUp instanceof Promise) {
+ castedButtonBackUp.then(doc => {
+ let toAssign = doc ? doc : new Doc();
+ this.curPresentation.presButtonBackUp = toAssign;
+ runInAction(() => this.presButtonBackUp = toAssign);
+ });
+
+ //if never instantiated a store doc yet
+ } else if (castedButtonBackUp instanceof Doc) {
+ let castedDoc: Doc = await castedButtonBackUp;
+ runInAction(() => this.presButtonBackUp = castedDoc);
+
+ } else {
+ runInAction(() => {
+ let toAssign = new Doc();
+ this.presButtonBackUp = toAssign;
+ this.curPresentation.presButtonBackUp = toAssign;
+ });
+
+ }
+
+
+ //storing the presentation status,ie. whether it was stopped or playing
+ let presStatusBackUp = BoolCast(this.curPresentation.presStatus);
+ runInAction(() => this.presStatus = presStatusBackUp);
+ }
+
+ /**
+ * This is the function that is called to retrieve the groups that have been stored and
+ * push them to the groupMappings.
+ */
+ retrieveGroupMappings = async () => {
+ let castedGroupDocs = await DocListCastAsync(this.presGroupBackUp.groupDocs);
+ if (castedGroupDocs !== undefined) {
+ castedGroupDocs.forEach(async (groupDoc: Doc, index: number) => {
+ let castedGrouping = await DocListCastAsync(groupDoc.grouping);
+ let castedKey = StrCast(groupDoc.presentIdStore, null);
+ if (castedGrouping) {
+ castedGrouping.forEach((doc: Doc) => {
+ doc.presentId = castedKey;
+ });
+ }
+ if (castedGrouping !== undefined && castedKey !== undefined) {
+ this.groupMappings.set(castedKey, castedGrouping);
+ }
+ });
+ }
+ }
+
+ //observable means render is re-called every time variable is changed
+ @observable
+ collapsed: boolean = false;
+ closePresentation = action(() => this.curPresentation.width = 0);
+ next = async () => {
+ const current = NumCast(this.curPresentation.selectedDoc);
+ //asking to get document at current index
+ let docAtCurrentNext = await this.getDocAtIndex(current + 1);
+ if (docAtCurrentNext === undefined) {
+ return;
+ }
+ //asking for it's presentation id
+ let curNextPresId = StrCast(docAtCurrentNext.presentId);
+ let nextSelected = current + 1;
+
+ //if curDoc is in a group, selection slides until last one, if not it's next one
+ if (this.groupMappings.has(curNextPresId)) {
+ let currentsArray = this.groupMappings.get(StrCast(docAtCurrentNext.presentId))!;
+ nextSelected = current + currentsArray.length - currentsArray.indexOf(docAtCurrentNext);
+
+ //end of grup so go beyond
+ if (nextSelected === current) nextSelected = current + 1;
+ }
+
+ this.gotoDocument(nextSelected, current);
+
+ }
+ back = async () => {
+ const current = NumCast(this.curPresentation.selectedDoc);
+ //requesting for the doc at current index
+ let docAtCurrent = await this.getDocAtIndex(current);
+ if (docAtCurrent === undefined) {
+ return;
+ }
+
+ //asking for its presentation id.
+ let curPresId = StrCast(docAtCurrent.presentId);
+ let prevSelected = current - 1;
+ let zoomOut: boolean = false;
+
+ //checking if this presentation id is mapped to a group, if so chosing the first element in group
+ if (this.groupMappings.has(curPresId)) {
+ let currentsArray = this.groupMappings.get(StrCast(docAtCurrent.presentId))!;
+ prevSelected = current - currentsArray.length + (currentsArray.length - currentsArray.indexOf(docAtCurrent)) - 1;
+ //end of grup so go beyond
+ if (prevSelected === current) prevSelected = current - 1;
+
+ //checking if any of the group members had used zooming in
+ currentsArray.forEach((doc: Doc) => {
+ //let presElem: PresentationElement | undefined = this.presElementsMappings.get(doc);
+ if (this.presElementsMappings.get(doc)!.selected[buttonIndex.Show]) {
+ zoomOut = true;
+ return;
+ }
+ });
+
+ }
+
+ // if a group set that flag to zero or a single element
+ //If so making sure to zoom out, which goes back to state before zooming action
+ if (current > 0) {
+ if (zoomOut || this.presElementsMappings.get(docAtCurrent)!.selected[buttonIndex.Show]) {
+ let prevScale = NumCast(this.childrenDocs[prevSelected].viewScale, null);
+ let curScale = DocumentManager.Instance.getScaleOfDocView(this.childrenDocs[current]);
+ if (prevScale !== undefined) {
+ if (prevScale !== curScale) {
+ DocumentManager.Instance.zoomIntoScale(docAtCurrent, prevScale);
+ }
+ }
+ }
+ }
+ this.gotoDocument(prevSelected, current);
+
+ }
+
+ /**
+ * This is the method that checks for the actions that need to be performed
+ * after the document has been presented, which involves 3 button options:
+ * Hide Until Presented, Hide After Presented, Fade After Presented
+ */
+ showAfterPresented = (index: number) => {
+ this.presElementsMappings.forEach((presElem: PresentationElement, key: Doc) => {
+ let selectedButtons: boolean[] = presElem.selected;
+ //the order of cases is aligned based on priority
+ if (selectedButtons[buttonIndex.HideTillPressed]) {
+ if (this.childrenDocs.indexOf(key) <= index) {
+ key.opacity = 1;
+ }
+ }
+ if (selectedButtons[buttonIndex.HideAfter]) {
+ if (this.childrenDocs.indexOf(key) < index) {
+ key.opacity = 0;
+ }
+ }
+ if (selectedButtons[buttonIndex.FadeAfter]) {
+ if (this.childrenDocs.indexOf(key) < index) {
+ key.opacity = 0.5;
+ }
+ }
+ });
+ }
+
+ /**
+ * This is the method that checks for the actions that need to be performed
+ * before the document has been presented, which involves 3 button options:
+ * Hide Until Presented, Hide After Presented, Fade After Presented
+ */
+ hideIfNotPresented = (index: number) => {
+ this.presElementsMappings.forEach((presElem: PresentationElement, key: Doc) => {
+ let selectedButtons: boolean[] = presElem.selected;
+
+ //the order of cases is aligned based on priority
+
+ if (selectedButtons[buttonIndex.HideAfter]) {
+ if (this.childrenDocs.indexOf(key) >= index) {
+ key.opacity = 1;
+ }
+ }
+ if (selectedButtons[buttonIndex.FadeAfter]) {
+ if (this.childrenDocs.indexOf(key) >= index) {
+ key.opacity = 1;
+ }
+ }
+ if (selectedButtons[buttonIndex.HideTillPressed]) {
+ if (this.childrenDocs.indexOf(key) > index) {
+ key.opacity = 0;
+ }
+ }
+ });
+ }
+
+ /**
+ * This method makes sure that cursor navigates to the element that
+ * has the option open and last in the group. If not in the group, and it has
+ * te option open, navigates to that element.
+ */
+ navigateToElement = async (curDoc: Doc, fromDoc: number) => {
+ let docToJump: Doc = curDoc;
+ let curDocPresId = StrCast(curDoc.presentId, null);
+ let willZoom: boolean = false;
+
+ //checking if in group
+ if (curDocPresId !== undefined) {
+ if (this.groupMappings.has(curDocPresId)) {
+ let currentDocGroup = this.groupMappings.get(curDocPresId)!;
+ currentDocGroup.forEach((doc: Doc, index: number) => {
+ let selectedButtons: boolean[] = this.presElementsMappings.get(doc)!.selected;
+ if (selectedButtons[buttonIndex.Navigate]) {
+ docToJump = doc;
+ willZoom = false;
+ }
+ if (selectedButtons[buttonIndex.Show]) {
+ docToJump = doc;
+ willZoom = true;
+ }
+ });
+ }
+
+ }
+ //docToJump stayed same meaning, it was not in the group or was the last element in the group
+ if (docToJump === curDoc) {
+ //checking if curDoc has navigation open
+ let curDocButtons = this.presElementsMappings.get(curDoc)!.selected;
+ if (curDocButtons[buttonIndex.Navigate]) {
+ this.jumpToTabOrRight(curDocButtons, curDoc);
+ } else if (curDocButtons[buttonIndex.Show]) {
+ let curScale = DocumentManager.Instance.getScaleOfDocView(this.childrenDocs[fromDoc]);
+ if (curDocButtons[buttonIndex.OpenRight]) {
+ //awaiting jump so that new scale can be found, since jumping is async
+ await DocumentManager.Instance.jumpToDocument(curDoc, true);
+ } else {
+ await DocumentManager.Instance.jumpToDocument(curDoc, true, undefined, doc => CollectionDockingView.Instance.AddTab(undefined, doc, undefined));
+ }
+
+ let newScale = DocumentManager.Instance.getScaleOfDocView(curDoc);
+ curDoc.viewScale = newScale;
+
+ //saving the scale user was on before zooming in
+ if (curScale !== 1) {
+ this.childrenDocs[fromDoc].viewScale = curScale;
+ }
+
+ }
+ return;
+ }
+ let curScale = DocumentManager.Instance.getScaleOfDocView(this.childrenDocs[fromDoc]);
+ let curDocButtons = this.presElementsMappings.get(docToJump)!.selected;
+
+
+ if (curDocButtons[buttonIndex.OpenRight]) {
+ //awaiting jump so that new scale can be found, since jumping is async
+ await DocumentManager.Instance.jumpToDocument(docToJump, willZoom);
+ } else {
+ await DocumentManager.Instance.jumpToDocument(docToJump, willZoom, undefined, doc => CollectionDockingView.Instance.AddTab(undefined, doc, undefined));
+ }
+ let newScale = DocumentManager.Instance.getScaleOfDocView(curDoc);
+ curDoc.viewScale = newScale;
+ //saving the scale that user was on
+ if (curScale !== 1) {
+ this.childrenDocs[fromDoc].viewScale = curScale;
+ }
+
+ }
+
+ /**
+ * This function checks if right option is clicked on a presentation element, if not it does open it as a tab
+ * with help of CollectionDockingView.
+ */
+ jumpToTabOrRight = (curDocButtons: boolean[], curDoc: Doc) => {
+ if (curDocButtons[buttonIndex.OpenRight]) {
+ DocumentManager.Instance.jumpToDocument(curDoc, false);
+ } else {
+ DocumentManager.Instance.jumpToDocument(curDoc, false, undefined, doc => CollectionDockingView.Instance.AddTab(undefined, doc, undefined));
+ }
+ }
+
+ /**
+ * Async function that supposedly return the doc that is located at given index.
+ */
+ getDocAtIndex = async (index: number) => {
+ const list = FieldValue(Cast(this.curPresentation.data, listSpec(Doc)));
+ if (!list) {
+ return undefined;
+ }
+ if (index < 0 || index >= list.length) {
+ return undefined;
+ }
+
+ this.curPresentation.selectedDoc = index;
+ //awaiting async call to finish to get Doc instance
+ const doc = await list[index];
+ return doc;
+ }
+
+ /**
+ * The function that removes a doc from a presentation. It also makes sure to
+ * do necessary updates to backUps and mappings stored locally.
+ */
+ @action
+ public RemoveDoc = async (index: number) => {
+ const value = FieldValue(Cast(this.curPresentation.data, listSpec(Doc)));
+ if (value) {
+ let removedDoc = await value.splice(index, 1)[0];
+
+ //removing the Presentation Element stored for it
+ this.presElementsMappings.delete(removedDoc);
+
+ let removedDocPresentId = StrCast(removedDoc.presentId);
+
+ //Removing it from local mapping of the groups
+ if (this.groupMappings.has(removedDocPresentId)) {
+ let removedDocsGroup = this.groupMappings.get(removedDocPresentId);
+ if (removedDocsGroup) {
+ removedDocsGroup.splice(removedDocsGroup.indexOf(removedDoc), 1);
+ if (removedDocsGroup.length === 0) {
+ this.groupMappings.delete(removedDocPresentId);
+ }
+ }
+ }
+
+
+ let castedList = Cast(this.presButtonBackUp.selectedButtonDocs, listSpec(Doc));
+ if (castedList) {
+ for (let doc of castedList) {
+ let curDoc = await doc;
+ let curDocId = StrCast(curDoc.docId);
+ if (curDocId === removedDoc[Id]) {
+ castedList.splice(castedList.indexOf(curDoc), 1);
+ break;
+
+ }
+ }
+ }
+
+ //removing it from the backup of groups
+ let castedGroupDocs = await DocListCastAsync(this.presGroupBackUp.groupDocs);
+ if (castedGroupDocs) {
+ castedGroupDocs.forEach(async (groupDoc: Doc, index: number) => {
+ let castedKey = StrCast(groupDoc.presentIdStore, null);
+ if (castedKey === removedDocPresentId) {
+ let castedGrouping = await DocListCastAsync(groupDoc.grouping);
+ if (castedGrouping) {
+ castedGrouping.splice(castedGrouping.indexOf(removedDoc), 1);
+ if (castedGrouping.length === 0) {
+ castedGroupDocs!.splice(castedGroupDocs!.indexOf(groupDoc), 1);
+ }
+ }
+ }
+
+ });
+
+ }
+
+
+ }
+ }
+
+ /**
+ * An alternative remove method that removes a doc from presentation by its actual
+ * reference.
+ */
+ public removeDocByRef = (doc: Doc) => {
+ let indexOfDoc = this.childrenDocs.indexOf(doc);
+ const value = FieldValue(Cast(this.curPresentation.data, listSpec(Doc)));
+ if (value) {
+ value.splice(indexOfDoc, 1)[0];
+ }
+ if (indexOfDoc !== - 1) {
+ return true;
+ }
+ return false;
+ }
+
+ //The function that is called when a document is clicked or reached through next or back.
+ //it'll also execute the necessary actions if presentation is playing.
+ @action
+ public gotoDocument = async (index: number, fromDoc: number) => {
+ const list = FieldValue(Cast(this.curPresentation.data, listSpec(Doc)));
+ if (!list) {
+ return;
+ }
+ if (index < 0 || index >= list.length) {
+ return;
+ }
+ this.curPresentation.selectedDoc = index;
+
+ if (!this.presStatus) {
+ this.presStatus = true;
+ this.startPresentation(index);
+ }
+
+ const doc = await list[index];
+ if (this.presStatus) {
+ this.navigateToElement(doc, fromDoc);
+ this.hideIfNotPresented(index);
+ this.showAfterPresented(index);
+ }
+
+ }
+
+ //Function that is called to resetGroupIds, so that documents get new groupIds at
+ //first load, when presentation is changed.
+ resetGroupIds = async () => {
+ let castedGroupDocs = await DocListCastAsync(this.presGroupBackUp.groupDocs);
+ if (castedGroupDocs !== undefined) {
+ castedGroupDocs.forEach(async (groupDoc: Doc, index: number) => {
+ let castedGrouping = await DocListCastAsync(groupDoc.grouping);
+ if (castedGrouping) {
+ castedGrouping.forEach((doc: Doc) => {
+ doc.presentId = Utils.GenerateGuid();
+ });
+ }
+ });
+ }
+ runInAction(() => this.groupMappings = new Map());
+ }
+
+ /**
+ * Adds a document to the presentation view
+ **/
+ @undoBatch
+ @action
+ public PinDoc(doc: Doc) {
+ //add this new doc to props.Document
+ const data = Cast(this.curPresentation.data, listSpec(Doc));
+ if (data) {
+ data.push(doc);
+ } else {
+ this.curPresentation.data = new List([doc]);
+ }
+
+ this.toggle(true);
+ }
+
+ //Function that sets the store of the children docs.
+ @action
+ setChildrenDocs = (docList: Doc[]) => {
+ this.childrenDocs = docList;
+ }
+
+ //The function that is called to render the play or pause button depending on
+ //if presentation is running or not.
+ renderPlayPauseButton = () => {
+ if (this.presStatus) {
+ return <button title="Reset Presentation" className="presentation-button" onClick={this.startOrResetPres}><FontAwesomeIcon icon="stop" /></button>;
+ } else {
+ return <button title="Start Presentation From Start" className="presentation-button" onClick={this.startOrResetPres}><FontAwesomeIcon icon="play" /></button>;
+ }
+ }
+
+ //The function that starts or resets presentaton functionally, depending on status flag.
+ @action
+ startOrResetPres = async () => {
+ if (this.presStatus) {
+ this.resetPresentation();
+ } else {
+ this.presStatus = true;
+ let startIndex = await this.findStartDocument();
+ this.startPresentation(startIndex);
+ const current = NumCast(this.curPresentation.selectedDoc);
+ this.gotoDocument(startIndex, current);
+ }
+ this.curPresentation.presStatus = this.presStatus;
+ }
+
+ /**
+ * This method is called to find the start document of presentation. So
+ * that when user presses on play, the correct presentation element will be
+ * selected.
+ */
+ findStartDocument = async () => {
+ let docAtZero = await this.getDocAtIndex(0);
+ if (docAtZero === undefined) {
+ return 0;
+ }
+ let docAtZeroPresId = StrCast(docAtZero.presentId);
+
+ if (this.groupMappings.has(docAtZeroPresId)) {
+ let group = this.groupMappings.get(docAtZeroPresId)!;
+ let lastDoc = group[group.length - 1];
+ return this.childrenDocs.indexOf(lastDoc);
+ } else {
+ return 0;
+ }
+ }
+
+ //The function that resets the presentation by removing every action done by it. It also
+ //stops the presentaton.
+ @action
+ resetPresentation = () => {
+ this.childrenDocs.forEach((doc: Doc) => {
+ doc.opacity = 1;
+ doc.viewScale = 1;
+ });
+ this.curPresentation.selectedDoc = 0;
+ this.presStatus = false;
+ this.curPresentation.presStatus = this.presStatus;
+ if (this.childrenDocs.length === 0) {
+ return;
+ }
+ DocumentManager.Instance.zoomIntoScale(this.childrenDocs[0], 1);
+ }
+
+
+ //The function that starts the presentation, also checking if actions should be applied
+ //directly at start.
+ startPresentation = (startIndex: number) => {
+ let selectedButtons: boolean[];
+ this.presElementsMappings.forEach((component: PresentationElement, doc: Doc) => {
+ selectedButtons = component.selected;
+ if (selectedButtons[buttonIndex.HideTillPressed]) {
+ if (this.childrenDocs.indexOf(doc) > startIndex) {
+ doc.opacity = 0;
+ }
+
+ }
+ if (selectedButtons[buttonIndex.HideAfter]) {
+ if (this.childrenDocs.indexOf(doc) < startIndex) {
+ doc.opacity = 0;
+ }
+ }
+ if (selectedButtons[buttonIndex.FadeAfter]) {
+ if (this.childrenDocs.indexOf(doc) < startIndex) {
+ doc.opacity = 0.5;
+ }
+ }
+
+ });
+
+ }
+
+ /**
+ * The function that is called to add a new presentation to the presentationView.
+ * It sets up te mappings and local copies of it. Resets the groupings and presentation.
+ * Makes the new presentation current selected, and retrieve the back-Ups if present.
+ */
+ @action
+ addNewPresentation = (presTitle: string) => {
+ //creating a new presentation doc
+ let newPresentationDoc = Docs.Create.TreeDocument([], { title: presTitle });
+ this.props.Documents.push(newPresentationDoc);
+
+ //setting that new doc as current
+ this.curPresentation = newPresentationDoc;
+
+ //storing the doc in local copies for easier access
+ let newGuid = Utils.GenerateGuid();
+ this.presentationsMapping.set(newGuid, newPresentationDoc);
+ this.presentationsKeyMapping.set(newPresentationDoc, newGuid);
+
+ //resetting the previous presentation's actions so that new presentation can be loaded.
+ this.resetGroupIds();
+ this.resetPresentation();
+ this.presElementsMappings = new Map();
+ this.currentSelectedPresValue = newGuid;
+ this.setPresentationBackUps();
+
+ }
+
+ /**
+ * The function that is called to change the current selected presentation.
+ * Changes the presentation, also resetting groupings and presentation in process.
+ * Plus retrieving the backUps for the newly selected presentation.
+ */
+ @action
+ getSelectedPresentation = (e: React.ChangeEvent<HTMLSelectElement>) => {
+ //get the guid of the selected presentation
+ let selectedGuid = e.target.value;
+ //set that as current presentation
+ this.curPresentation = this.presentationsMapping.get(selectedGuid)!;
+
+ //reset current Presentations local things so that new one can be loaded
+ this.resetGroupIds();
+ this.resetPresentation();
+ this.presElementsMappings = new Map();
+ this.currentSelectedPresValue = selectedGuid;
+ this.setPresentationBackUps();
+
+
+ }
+
+ /**
+ * The function that is called to render either select for presentations, or title inputting.
+ */
+ renderSelectOrPresSelection = () => {
+ let presentationList = DocListCast(this.props.Documents);
+ if (this.PresTitleInputOpen || this.PresTitleChangeOpen) {
+ return <input ref={(e) => this.titleInputElement = e!} type="text" className="presentationView-title" placeholder="Enter Name!" onKeyDown={this.submitPresentationTitle} />;
+ } else {
+ return <select value={this.currentSelectedPresValue} id="pres_selector" className="presentationView-title" onChange={this.getSelectedPresentation}>
+ {presentationList.map((doc: Doc, index: number) => {
+ let mappedGuid = this.presentationsKeyMapping.get(doc);
+ let docGuid: string = mappedGuid ? mappedGuid.toString() : "";
+ return <option key={docGuid} value={docGuid}>{StrCast(doc.title)}</option>;
+ })}
+ </select>;
+ }
+ }
+
+ /**
+ * The function that is called on enter press of title input. It gives the
+ * new presentation the title user entered. If nothing is entered, gives a default title.
+ */
+ @action
+ submitPresentationTitle = (e: React.KeyboardEvent) => {
+ if (e.keyCode === 13) {
+ let presTitle = this.titleInputElement!.value;
+ this.titleInputElement!.value = "";
+ if (this.PresTitleInputOpen) {
+ if (presTitle === "") {
+ presTitle = "Presentation";
+ }
+ this.PresTitleInputOpen = false;
+ this.addNewPresentation(presTitle);
+ } else if (this.PresTitleChangeOpen) {
+ this.PresTitleChangeOpen = false;
+ this.changePresentationTitle(presTitle);
+ }
+ }
+ }
+
+ /**
+ * The function that is called to remove a presentation from all its copies, and the main Container's
+ * list. Sets up the next presentation as current.
+ */
+ @action
+ removePresentation = async () => {
+ if (this.presentationsMapping.size !== 1) {
+ let presentationList = Cast(this.props.Documents, listSpec(Doc));
+ let batch = UndoManager.StartBatch("presRemoval");
+
+ //getting the presentation that will be removed
+ let removedDoc = this.presentationsMapping.get(this.currentSelectedPresValue!);
+ //that presentation is removed
+ presentationList!.splice(presentationList!.indexOf(removedDoc!), 1);
+
+ //its mappings are removed from local copies
+ this.presentationsKeyMapping.delete(removedDoc!);
+ this.presentationsMapping.delete(this.currentSelectedPresValue!);
+
+ //the next presentation is set as current
+ let remainingPresentations = this.presentationsMapping.values();
+ let nextDoc = remainingPresentations.next().value;
+ this.curPresentation = nextDoc;
+
+
+ //Storing these for being able to undo changes
+ let curGuid = this.currentSelectedPresValue!;
+ let curPresStatus = this.presStatus;
+
+ //resetting the groups and presentation actions so that next presentation gets loaded
+ this.resetGroupIds();
+ this.resetPresentation();
+ this.currentSelectedPresValue = this.presentationsKeyMapping.get(nextDoc)!.toString();
+ this.setPresentationBackUps();
+
+ //Storing for undo
+ let currentGroups = this.groupMappings;
+ let curPresElemMapping = this.presElementsMappings;
+
+ //Event to undo actions that are not related to doc directly, aka. local things
+ UndoManager.AddEvent({
+ undo: action(() => {
+ this.curPresentation = removedDoc!;
+ this.presentationsMapping.set(curGuid, removedDoc!);
+ this.presentationsKeyMapping.set(removedDoc!, curGuid);
+ this.currentSelectedPresValue = curGuid;
+
+ this.presStatus = curPresStatus;
+ this.groupMappings = currentGroups;
+ this.presElementsMappings = curPresElemMapping;
+ this.setPresentationBackUps();
+
+ }),
+ redo: action(() => {
+ this.curPresentation = nextDoc;
+ this.presStatus = false;
+ this.presentationsKeyMapping.delete(removedDoc!);
+ this.presentationsMapping.delete(curGuid);
+ this.currentSelectedPresValue = this.presentationsKeyMapping.get(nextDoc)!.toString();
+ this.setPresentationBackUps();
+
+ }),
+ });
+
+ batch.end();
+ }
+ }
+
+ /**
+ * The function that is called to change title of presentation to what user entered.
+ */
+ @undoBatch
+ changePresentationTitle = (newTitle: string) => {
+ if (newTitle === "") {
+ return;
+ }
+ this.curPresentation.title = newTitle;
+ }
+
+ /**
+ * On pointer down element that is catched on resizer of te
+ * presentation view. Sets up the event listeners to change the size with
+ * mouse move.
+ */
+ _downsize = 0;
+ onPointerDown = (e: React.PointerEvent) => {
+ this._downsize = e.clientX;
+ document.removeEventListener("pointermove", this.onPointerMove);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ document.addEventListener("pointermove", this.onPointerMove);
+ document.addEventListener("pointerup", this.onPointerUp);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ /**
+ * Changes the size of the presentation view, with mouse move.
+ * Minimum size is set to 300, so that every button is visible.
+ */
+ @action
+ onPointerMove = (e: PointerEvent) => {
+
+ this.curPresentation.width = Math.max(window.innerWidth - e.clientX, presMinWidth);
+ }
+
+ /**
+ * The method that is called on pointer up event. It checks if the button is just
+ * clicked so that presentation view will be closed. The way it's done is to check
+ * for minimal pixel change like 4, and accept it as it's just a click on top of the dragger.
+ */
+ @action
+ onPointerUp = (e: PointerEvent) => {
+ if (Math.abs(e.clientX - this._downsize) < 4) {
+ let presWidth = NumCast(this.curPresentation.width);
+ if (presWidth - presMinWidth !== 0) {
+ this.curPresentation.width = 0;
+ }
+ if (presWidth === 0) {
+ this.curPresentation.width = presMinWidth;
+ }
+ }
+ document.removeEventListener("pointermove", this.onPointerMove);
+ document.removeEventListener("pointerup", this.onPointerUp);
+ }
+
+ /**
+ * This function is a setter that opens up the
+ * presentation mode, by setting it's render flag
+ * to true. It also closes the presentation view.
+ */
+ @action
+ openPresMode = () => {
+ if (!this.presMode) {
+ this.curPresentation.width = 0;
+ this.presMode = true;
+ }
+ }
+
+ /**
+ * This function closes the presentation mode by setting its
+ * render flag to false. It also opens up the presentation view.
+ * By setting it to it's minimum size.
+ */
+ @action
+ closePresMode = () => {
+ if (this.presMode) {
+ this.presMode = false;
+ this.curPresentation.width = presMinWidth;
+ }
+
+ }
+
+ /**
+ * Function that is called to render the presentation mode, depending on its flag.
+ */
+ renderPresMode = () => {
+ if (this.presMode) {
+ return <PresModeMenu next={this.next} back={this.back} startOrResetPres={this.startOrResetPres} presStatus={this.presStatus} closePresMode={this.closePresMode} />;
+ } else {
+ return (null);
+ }
+
+ }
+
+ render() {
+
+ let width = NumCast(this.curPresentation.width);
+
+ return (
+ <div>
+ <div className="presentationView-cont" onPointerEnter={action(() => !this.persistOpacity && (this.opacity = 1))} onPointerLeave={action(() => !this.persistOpacity && (this.opacity = 0.4))} style={{ width: width, overflowY: "scroll", overflowX: "hidden", opacity: this.opacity, transition: "0.7s opacity ease" }}>
+ <div className="presentationView-heading">
+ {this.renderSelectOrPresSelection()}
+ <button title="Close Presentation" className='presentation-icon' onClick={this.closePresentation}><FontAwesomeIcon icon={"times"} /></button>
+ <button title="Open Presentation Mode" className="presentation-icon" style={{ marginRight: 10 }} onClick={this.openPresMode}><FontAwesomeIcon icon={"eye"} /></button>
+ <button title="Add Presentation" className="presentation-icon" style={{ marginRight: 10 }} onClick={() => {
+ runInAction(() => { if (this.PresTitleChangeOpen) { this.PresTitleChangeOpen = false; } });
+ runInAction(() => this.PresTitleInputOpen ? this.PresTitleInputOpen = false : this.PresTitleInputOpen = true);
+ }}><FontAwesomeIcon icon={"plus"} /></button>
+ <button title="Remove Presentation" className='presentation-icon' style={{ marginRight: 10 }} onClick={this.removePresentation}><FontAwesomeIcon icon={"minus"} /></button>
+ <button title="Change Presentation Title" className="presentation-icon" style={{ marginRight: 10 }} onClick={() => {
+ runInAction(() => { if (this.PresTitleInputOpen) { this.PresTitleInputOpen = false; } });
+ runInAction(() => this.PresTitleChangeOpen ? this.PresTitleChangeOpen = false : this.PresTitleChangeOpen = true);
+ }}><FontAwesomeIcon icon={"edit"} /></button>
+ </div>
+ <div className="presentation-buttons">
+ <button title="Back" className="presentation-button" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button>
+ {this.renderPlayPauseButton()}
+ <button title="Next" className="presentation-button" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button>
+ </div>
+
+ <PresentationViewList
+ mainDocument={this.curPresentation}
+ deleteDocument={this.RemoveDoc}
+ gotoDocument={this.gotoDocument}
+ groupMappings={this.groupMappings}
+ PresElementsMappings={this.presElementsMappings}
+ setChildrenDocs={this.setChildrenDocs}
+ presStatus={this.presStatus}
+ presButtonBackUp={this.presButtonBackUp}
+ presGroupBackUp={this.presGroupBackUp}
+ removeDocByRef={this.removeDocByRef}
+ clearElemMap={() => this.presElementsMappings.clear()}
+ />
+ <input
+ type="checkbox"
+ onChange={action((e: React.ChangeEvent<HTMLInputElement>) => {
+ this.persistOpacity = e.target.checked;
+ this.opacity = this.persistOpacity ? 1 : 0.4;
+ })}
+ checked={this.persistOpacity}
+ style={{ position: "absolute", bottom: 5, left: 5 }}
+ onPointerEnter={action(() => this.labelOpacity = 1)}
+ onPointerLeave={action(() => this.labelOpacity = 0)}
+ />
+ <p style={{ position: "absolute", bottom: 1, left: 22, opacity: this.labelOpacity, transition: "0.7s opacity ease" }}>opacity {this.persistOpacity ? "persistent" : "on focus"}</p>
+ </div>
+ <div className="mainView-libraryHandle"
+ style={{ cursor: "ew-resize", right: `${width - 10}px`, backgroundColor: "white", opacity: this.opacity, transition: "0.7s opacity ease" }}
+ onPointerDown={this.onPointerDown}>
+ <span title="library View Dragger" style={{ width: "100%", height: "100%", position: "absolute" }} />
+ </div>
+ {this.renderPresMode()}
+
+ </div>
+ );
+ }
+}
diff --git a/src/extensions/ArrayExtensions.ts b/src/extensions/ArrayExtensions.ts
new file mode 100644
index 000000000..422a10dbc
--- /dev/null
+++ b/src/extensions/ArrayExtensions.ts
@@ -0,0 +1,13 @@
+function Assign() {
+
+ Array.prototype.lastElement = function <T>() {
+ if (!this.length) {
+ return undefined;
+ }
+ const last: T = this[this.length - 1];
+ return last;
+ };
+
+}
+
+export { Assign }; \ No newline at end of file
diff --git a/src/extensions/General/Extensions.ts b/src/extensions/General/Extensions.ts
new file mode 100644
index 000000000..4b6d05d5f
--- /dev/null
+++ b/src/extensions/General/Extensions.ts
@@ -0,0 +1,9 @@
+import { Assign as ArrayAssign } from "../ArrayExtensions";
+import { Assign as StringAssign } from "../StringExtensions";
+
+function AssignAllExtensions() {
+ ArrayAssign();
+ StringAssign();
+}
+
+export { AssignAllExtensions }; \ No newline at end of file
diff --git a/src/extensions/General/ExtensionsTypings.ts b/src/extensions/General/ExtensionsTypings.ts
new file mode 100644
index 000000000..370157ed0
--- /dev/null
+++ b/src/extensions/General/ExtensionsTypings.ts
@@ -0,0 +1,8 @@
+interface Array<T> {
+ lastElement(): T;
+}
+
+interface String {
+ removeTrailingNewlines(): string;
+ hasNewline(): boolean;
+} \ No newline at end of file
diff --git a/src/extensions/StringExtensions.ts b/src/extensions/StringExtensions.ts
new file mode 100644
index 000000000..2c76e56c8
--- /dev/null
+++ b/src/extensions/StringExtensions.ts
@@ -0,0 +1,17 @@
+function Assign() {
+
+ String.prototype.removeTrailingNewlines = function () {
+ let sliced = this;
+ while (sliced.endsWith("\n")) {
+ sliced = sliced.substring(0, this.length - 1);
+ }
+ return sliced as string;
+ };
+
+ String.prototype.hasNewline = function () {
+ return this.endsWith("\n");
+ };
+
+}
+
+export { Assign }; \ No newline at end of file
diff --git a/src/new_fields/RichTextField.ts b/src/new_fields/RichTextField.ts
index 1b52e6f82..d2f76c969 100644
--- a/src/new_fields/RichTextField.ts
+++ b/src/new_fields/RichTextField.ts
@@ -4,11 +4,6 @@ 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 {
@@ -28,48 +23,4 @@ export class RichTextField extends ObjectField {
return `new RichTextField("${this.Data}")`;
}
- public static Initialize = (initial: string) => {
- !initial.length && (initial = " ");
- let pos = initial.length + 1;
- return `{"doc":{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"${initial}"}]}]},"selection":{"type":"text","anchor":${pos},"head":${pos}}}`;
- }
-
- [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/new_fields/RichTextUtils.ts b/src/new_fields/RichTextUtils.ts
new file mode 100644
index 000000000..601939ed2
--- /dev/null
+++ b/src/new_fields/RichTextUtils.ts
@@ -0,0 +1,519 @@
+import { EditorState, Transaction, TextSelection } from "prosemirror-state";
+import { Node, Fragment, Mark, MarkType } from "prosemirror-model";
+import { RichTextField } from "./RichTextField";
+import { docs_v1 } from "googleapis";
+import { GoogleApiClientUtils } from "../client/apis/google_docs/GoogleApiClientUtils";
+import { FormattedTextBox } from "../client/views/nodes/FormattedTextBox";
+import { Opt, Doc } from "./Doc";
+import Color = require('color');
+import { sinkListItem } from "prosemirror-schema-list";
+import { Utils } from "../Utils";
+import { RouteStore } from "../server/RouteStore";
+import { Docs } from "../client/documents/Documents";
+import { schema } from "../client/util/RichTextSchema";
+import { GooglePhotos } from "../client/apis/google_docs/GooglePhotosClientUtils";
+import { DocServer } from "../client/DocServer";
+import { Cast, StrCast } from "./Types";
+import { Id } from "./FieldSymbols";
+import { DocumentView } from "../client/views/nodes/DocumentView";
+import { AssertionError } from "assert";
+import { Identified } from "../client/Network";
+
+export namespace RichTextUtils {
+
+ const delimiter = "\n";
+ const joiner = "";
+
+
+ export const Initialize = (initial?: string) => {
+ let content: any[] = [];
+ let state = {
+ doc: {
+ type: "doc",
+ content,
+ },
+ selection: {
+ type: "text",
+ anchor: 0,
+ head: 0
+ }
+ };
+ if (initial && initial.length) {
+ content.push({
+ type: "paragraph",
+ content: {
+ type: "text",
+ text: initial
+ }
+ });
+ state.selection.anchor = state.selection.head = initial.length + 1;
+ }
+ return JSON.stringify(state);
+ };
+
+ export const Synthesize = (plainText: string, oldState?: RichTextField) => {
+ return new RichTextField(ToProsemirrorState(plainText, oldState));
+ };
+
+ export const ToPlainText = (state: EditorState) => {
+ // Because we're working with plain text, just concatenate all paragraphs
+ let content = state.doc.content;
+ let paragraphs: Node<any>[] = [];
+ content.forEach(node => node.type.name === "paragraph" && paragraphs.push(node));
+
+ // Functions to flatten ProseMirror paragraph objects (and their components) to plain text
+ // Concatentate paragraphs and string the result together
+ let textParagraphs: string[] = paragraphs.map(paragraph => {
+ let text: string[] = [];
+ paragraph.content.forEach(node => node.text && text.push(node.text));
+ return text.join(joiner) + delimiter;
+ });
+ let plainText = textParagraphs.join(joiner);
+ return plainText.substring(0, plainText.length - 1);
+ };
+
+ export const ToProsemirrorState = (plainText: string, oldState?: RichTextField) => {
+ // 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(oldState ? oldState.Data : Initialize());
+ 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 just built
+ return JSON.stringify(parsed);
+ };
+
+ export namespace GoogleDocs {
+
+ export const Export = async (state: EditorState): Promise<GoogleApiClientUtils.Docs.Content> => {
+ const nodes: (Node<any> | null)[] = [];
+ let text = ToPlainText(state);
+ state.doc.content.forEach(node => {
+ if (!node.childCount) {
+ nodes.push(null);
+ } else {
+ node.content.forEach(child => nodes.push(child));
+ }
+ });
+ const requests = await marksToStyle(nodes);
+ return { text, requests };
+ };
+
+ interface ImageTemplate {
+ width: number;
+ title: string;
+ url: string;
+ }
+
+ const parseInlineObjects = async (document: docs_v1.Schema$Document): Promise<Map<string, ImageTemplate>> => {
+ const inlineObjectMap = new Map<string, ImageTemplate>();
+ const inlineObjects = document.inlineObjects;
+
+ if (inlineObjects) {
+ const objects = Object.keys(inlineObjects).map(objectId => inlineObjects[objectId]);
+ const mediaItems: MediaItem[] = objects.map(object => {
+ const embeddedObject = object.inlineObjectProperties!.embeddedObject!;
+ const baseUrl = embeddedObject.imageProperties!.contentUri!;
+ const filename = `upload_${Utils.GenerateGuid()}.png`;
+ return { baseUrl, filename };
+ });
+
+ const uploads = await Identified.PostToServer(RouteStore.googlePhotosMediaDownload, { mediaItems });
+
+ if (uploads.length !== mediaItems.length) {
+ throw new AssertionError({ expected: mediaItems.length, actual: uploads.length, message: "Error with internally uploading inlineObjects!" });
+ }
+
+ for (let i = 0; i < objects.length; i++) {
+ const object = objects[i];
+ const { fileNames } = uploads[i];
+ const embeddedObject = object.inlineObjectProperties!.embeddedObject!;
+ const size = embeddedObject.size!;
+ const width = size.width!.magnitude!;
+ const url = Utils.fileUrl(fileNames.clean);
+
+ inlineObjectMap.set(object.objectId!, {
+ title: embeddedObject.title || `Imported Image from ${document.title}`,
+ width,
+ url
+ });
+ }
+ }
+ return inlineObjectMap;
+ };
+
+ type BulletPosition = { value: number, sinks: number };
+
+ interface MediaItem {
+ baseUrl: string;
+ filename: string;
+ }
+
+ export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId, textNote: Doc): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => {
+ const document = await GoogleApiClientUtils.Docs.retrieve({ documentId });
+ if (!document) {
+ return undefined;
+ }
+ const inlineObjectMap = await parseInlineObjects(document);
+ const title = document.title!;
+ const { text, paragraphs } = GoogleApiClientUtils.Docs.Utils.extractText(document);
+ let state = FormattedTextBox.blankState();
+ let structured = parseLists(paragraphs);
+
+ let position = 3;
+ let lists: ListGroup[] = [];
+ const indentMap = new Map<ListGroup, BulletPosition[]>();
+ let globalOffset = 0;
+ const nodes: Node<any>[] = [];
+ for (let element of structured) {
+ if (Array.isArray(element)) {
+ lists.push(element);
+ let positions: BulletPosition[] = [];
+ let items = element.map(paragraph => {
+ let item = listItem(state.schema, paragraph.contents);
+ let sinks = paragraph.bullet!;
+ positions.push({
+ value: position + globalOffset,
+ sinks
+ });
+ position += item.nodeSize;
+ globalOffset += 2 * sinks;
+ return item;
+ });
+ indentMap.set(element, positions);
+ nodes.push(list(state.schema, items));
+ } else {
+ if (element.contents.some(child => "inlineObjectId" in child)) {
+ const group = element.contents;
+ group.forEach((child, i) => {
+ let node: Opt<Node<any>>;
+ if ("inlineObjectId" in child) {
+ node = imageNode(state.schema, inlineObjectMap.get(child.inlineObjectId!)!, textNote);
+ } else if ("content" in child && (i !== group.length - 1 || child.content!.removeTrailingNewlines().length)) {
+ node = paragraphNode(state.schema, [child]);
+ }
+ if (node) {
+ position += node.nodeSize;
+ nodes.push(node);
+ }
+ });
+ } else {
+ let paragraph = paragraphNode(state.schema, element.contents);
+ nodes.push(paragraph);
+ position += paragraph.nodeSize;
+ }
+ }
+ }
+ state = state.apply(state.tr.replaceWith(0, 2, nodes));
+
+ let sink = sinkListItem(state.schema.nodes.list_item);
+ let dispatcher = (tr: Transaction) => state = state.apply(tr);
+ for (let list of lists) {
+ for (let pos of indentMap.get(list)!) {
+ let resolved = state.doc.resolve(pos.value);
+ state = state.apply(state.tr.setSelection(new TextSelection(resolved)));
+ for (let i = 0; i < pos.sinks; i++) {
+ sink(state, dispatcher);
+ }
+ }
+ }
+
+ return { title, text, state };
+ };
+
+ type Paragraph = GoogleApiClientUtils.Docs.Utils.DeconstructedParagraph;
+ type ListGroup = Paragraph[];
+ type PreparedParagraphs = (ListGroup | Paragraph)[];
+
+ const parseLists = (paragraphs: ListGroup) => {
+ let groups: PreparedParagraphs = [];
+ let group: ListGroup = [];
+ for (let paragraph of paragraphs) {
+ if (paragraph.bullet !== undefined) {
+ group.push(paragraph);
+ } else {
+ if (group.length) {
+ groups.push(group);
+ group = [];
+ }
+ groups.push(paragraph);
+ }
+ }
+ group.length && groups.push(group);
+ return groups;
+ };
+
+ const listItem = (schema: any, runs: docs_v1.Schema$TextRun[]): Node => {
+ return schema.node("list_item", null, paragraphNode(schema, runs));
+ };
+
+ const list = (schema: any, items: Node[]): Node => {
+ return schema.node("bullet_list", null, items);
+ };
+
+ const paragraphNode = (schema: any, runs: docs_v1.Schema$TextRun[]): Node => {
+ let children = runs.map(run => textNode(schema, run)).filter(child => child !== undefined);
+ let fragment = children.length ? Fragment.from(children) : undefined;
+ return schema.node("paragraph", null, fragment);
+ };
+
+ const imageNode = (schema: any, image: ImageTemplate, textNote: Doc) => {
+ const { url: src, width } = image;
+ let docid: string;
+ const guid = Utils.GenerateDeterministicGuid(src);
+ const backingDocId = StrCast(textNote[guid]);
+ if (!backingDocId) {
+ const backingDoc = Docs.Create.ImageDocument(src, { width: 300, height: 300 });
+ DocumentView.makeCustomViewClicked(backingDoc, undefined);
+ docid = backingDoc[Id];
+ textNote[guid] = docid;
+ } else {
+ docid = backingDocId;
+ }
+ return schema.node("image", { src, width, docid, float: null, location: "onRight" });
+ };
+
+ const textNode = (schema: any, run: docs_v1.Schema$TextRun) => {
+ let text = run.content!.removeTrailingNewlines();
+ return text.length ? schema.text(text, styleToMarks(schema, run.textStyle)) : undefined;
+ };
+
+ const StyleToMark = new Map<keyof docs_v1.Schema$TextStyle, keyof typeof schema.marks>([
+ ["bold", "strong"],
+ ["italic", "em"],
+ ["foregroundColor", "pFontColor"],
+ ["fontSize", "pFontSize"]
+ ]);
+
+ const styleToMarks = (schema: any, textStyle?: docs_v1.Schema$TextStyle) => {
+ if (!textStyle) {
+ return undefined;
+ }
+ let marks: Mark[] = [];
+ Object.keys(textStyle).forEach(key => {
+ let value: any;
+ let targeted = key as keyof docs_v1.Schema$TextStyle;
+ if (value = textStyle[targeted]) {
+ let attributes: any = {};
+ let converted = StyleToMark.get(targeted) || targeted;
+
+ value.url && (attributes.href = value.url);
+ if (value.color) {
+ let object = value.color.rgbColor;
+ attributes.color = Color.rgb(["red", "green", "blue"].map(color => object[color] * 255 || 0)).hex();
+ }
+ if (value.magnitude) {
+ attributes.fontSize = value.magnitude;
+ }
+
+ if (converted === "weightedFontFamily") {
+ converted = ImportFontFamilyMapping.get(value.fontFamily) || "timesNewRoman";
+ }
+
+ let mapped = schema.marks[converted];
+ if (!mapped) {
+ alert(`No mapping found for ${converted}!`);
+ return;
+ }
+
+ let mark = schema.mark(mapped, attributes);
+ mark && marks.push(mark);
+ }
+ });
+ return marks;
+ };
+
+ const MarkToStyle = new Map<keyof typeof schema.marks, keyof docs_v1.Schema$TextStyle>([
+ ["strong", "bold"],
+ ["em", "italic"],
+ ["pFontColor", "foregroundColor"],
+ ["pFontSize", "fontSize"],
+ ["timesNewRoman", "weightedFontFamily"],
+ ["georgia", "weightedFontFamily"],
+ ["comicSans", "weightedFontFamily"],
+ ["tahoma", "weightedFontFamily"],
+ ["impact", "weightedFontFamily"]
+ ]);
+
+ const ExportFontFamilyMapping = new Map<string, string>([
+ ["timesNewRoman", "Times New Roman"],
+ ["arial", "Arial"],
+ ["georgia", "Georgia"],
+ ["comicSans", "Comic Sans MS"],
+ ["tahoma", "Tahoma"],
+ ["impact", "Impact"]
+ ]);
+
+ const ImportFontFamilyMapping = new Map<string, string>([
+ ["Times New Roman", "timesNewRoman"],
+ ["Arial", "arial"],
+ ["Georgia", "georgia"],
+ ["Comic Sans MS", "comicSans"],
+ ["Tahoma", "tahoma"],
+ ["Impact", "impact"]
+ ]);
+
+ const ignored = ["user_mark"];
+
+ const marksToStyle = async (nodes: (Node<any> | null)[]): Promise<docs_v1.Schema$Request[]> => {
+ let requests: docs_v1.Schema$Request[] = [];
+ let position = 1;
+ for (let node of nodes) {
+ if (node === null) {
+ position += 2;
+ continue;
+ }
+ const { marks, attrs, nodeSize } = node;
+ const textStyle: docs_v1.Schema$TextStyle = {};
+ const information: LinkInformation = {
+ startIndex: position,
+ endIndex: position + nodeSize,
+ textStyle
+ };
+ let mark: Mark<any>;
+ const markMap = BuildMarkMap(marks);
+ for (let markName of Object.keys(schema.marks)) {
+ if (ignored.includes(markName) || !(mark = markMap[markName])) {
+ continue;
+ }
+ let converted = MarkToStyle.get(markName) || markName as keyof docs_v1.Schema$TextStyle;
+ let value: any = true;
+ if (!converted) {
+ continue;
+ }
+ const { attrs } = mark;
+ switch (converted) {
+ case "link":
+ let url = attrs.href;
+ const delimiter = "/doc/";
+ const alreadyShared = "?sharing=true";
+ if (new RegExp(window.location.origin + delimiter).test(url) && !url.endsWith(alreadyShared)) {
+ const linkDoc = await DocServer.GetRefField(url.split(delimiter)[1]);
+ if (linkDoc instanceof Doc) {
+ let exported = (await Cast(linkDoc.anchor2, Doc))!;
+ if (!exported.customLayout) {
+ exported = Doc.MakeAlias(exported);
+ DocumentView.makeCustomViewClicked(exported, undefined);
+ linkDoc.anchor2 = exported;
+ }
+ url = Utils.shareUrl(exported[Id]);
+ }
+ }
+ value = { url };
+ textStyle.foregroundColor = fromRgb.blue;
+ textStyle.bold = true;
+ break;
+ case "fontSize":
+ value = { magnitude: attrs.fontSize, unit: "PT" };
+ break;
+ case "foregroundColor":
+ value = fromHex(attrs.color);
+ break;
+ case "weightedFontFamily":
+ value = { fontFamily: ExportFontFamilyMapping.get(markName) };
+ }
+ let matches: RegExpExecArray | null;
+ if ((matches = /p(\d+)/g.exec(markName)) !== null) {
+ converted = "fontSize";
+ value = { magnitude: parseInt(matches[1].replace("px", "")), unit: "PT" };
+ }
+ textStyle[converted] = value;
+ }
+ if (Object.keys(textStyle).length) {
+ requests.push(EncodeStyleUpdate(information));
+ }
+ if (node.type.name === "image") {
+ const width = attrs.width;
+ requests.push(await EncodeImage({
+ startIndex: position + nodeSize - 1,
+ uri: attrs.src,
+ width: Number(typeof width === "string" ? width.replace("px", "") : width)
+ }));
+ }
+ position += nodeSize;
+ }
+ return requests;
+ };
+
+ const BuildMarkMap = (marks: Mark<any>[]) => {
+ const markMap: { [type: string]: Mark<any> } = {};
+ marks.forEach(mark => markMap[mark.type.name] = mark);
+ return markMap;
+ };
+
+ interface LinkInformation {
+ startIndex: number;
+ endIndex: number;
+ textStyle: docs_v1.Schema$TextStyle;
+ }
+
+ interface ImageInformation {
+ startIndex: number;
+ width: number;
+ uri: string;
+ }
+
+ namespace fromRgb {
+
+ export const convert = (red: number, green: number, blue: number): docs_v1.Schema$OptionalColor => {
+ return {
+ color: {
+ rgbColor: {
+ red: red / 255,
+ green: green / 255,
+ blue: blue / 255
+ }
+ }
+ };
+ };
+
+ export const red = convert(255, 0, 0);
+ export const green = convert(0, 255, 0);
+ export const blue = convert(0, 0, 255);
+
+ }
+
+ const fromHex = (color: string): docs_v1.Schema$OptionalColor => {
+ const c = Color(color);
+ return fromRgb.convert(c.red(), c.green(), c.blue());
+ };
+
+ const EncodeStyleUpdate = (information: LinkInformation): docs_v1.Schema$Request => {
+ const { startIndex, endIndex, textStyle } = information;
+ return {
+ updateTextStyle: {
+ fields: "*",
+ range: { startIndex, endIndex },
+ textStyle
+ } as docs_v1.Schema$UpdateTextStyleRequest
+ };
+ };
+
+ const EncodeImage = async (information: ImageInformation) => {
+ const source = [Docs.Create.ImageDocument(information.uri)];
+ const baseUrls = await GooglePhotos.Transactions.UploadThenFetch(source);
+ if (baseUrls) {
+ return {
+ insertInlineImage: {
+ uri: baseUrls[0],
+ objectSize: { width: { magnitude: information.width, unit: "PT" } },
+ location: { index: information.startIndex }
+ }
+ };
+ }
+ return {};
+ };
+ }
+
+} \ No newline at end of file
diff --git a/src/server/DashUploadUtils.ts b/src/server/DashUploadUtils.ts
new file mode 100644
index 000000000..4230e9b17
--- /dev/null
+++ b/src/server/DashUploadUtils.ts
@@ -0,0 +1,171 @@
+import * as fs from 'fs';
+import { Utils } from '../Utils';
+import * as path from 'path';
+import * as sharp from 'sharp';
+import request = require('request-promise');
+
+const uploadDirectory = path.join(__dirname, './public/files/');
+
+export namespace DashUploadUtils {
+
+ export interface Size {
+ width: number;
+ suffix: string;
+ }
+
+ export const Sizes: { [size: string]: Size } = {
+ SMALL: { width: 100, suffix: "_s" },
+ MEDIUM: { width: 400, suffix: "_m" },
+ LARGE: { width: 900, suffix: "_l" },
+ };
+
+ const gifs = [".gif"];
+ const pngs = [".png"];
+ const jpgs = [".jpg", ".jpeg"];
+ const imageFormats = [...pngs, ...jpgs, ...gifs];
+ const videoFormats = [".mov", ".mp4"];
+
+ const size = "content-length";
+ const type = "content-type";
+
+ export interface UploadInformation {
+ mediaPaths: string[];
+ fileNames: { [key: string]: string };
+ contentSize?: number;
+ contentType?: string;
+ }
+
+ const generate = (prefix: string, url: string) => `${prefix}upload_${Utils.GenerateGuid()}${path.extname(url).toLowerCase()}`;
+ const sanitize = (filename: string) => filename.replace(/\s+/g, "_");
+
+ /**
+ * Uploads an image specified by the @param source to Dash's /public/files/
+ * directory, and returns information generated during that upload
+ *
+ * @param {string} source is either the absolute path of an already uploaded image or
+ * the url of a remote image
+ * @param {string} filename dictates what to call the image. If not specified,
+ * the name {@param prefix}_upload_{GUID}
+ * @param {string} prefix is a string prepended to the generated image name in the
+ * event that @param filename is not specified
+ *
+ * @returns {UploadInformation} This method returns
+ * 1) the paths to the uploaded images (plural due to resizing)
+ * 2) the file name of each of the resized images
+ * 3) the size of the image, in bytes (4432130)
+ * 4) the content type of the image, i.e. image/(jpeg | png | ...)
+ */
+ export const UploadImage = async (source: string, filename?: string, prefix: string = ""): Promise<UploadInformation> => {
+ const metadata = await InspectImage(source);
+ return UploadInspectedImage(metadata, filename, prefix);
+ };
+
+ export interface InspectionResults {
+ isLocal: boolean;
+ stream: any;
+ normalizedUrl: string;
+ contentSize?: number;
+ contentType?: string;
+ }
+
+ /**
+ * Based on the url's classification as local or remote, gleans
+ * as much information as possible about the specified image
+ *
+ * @param source is the path or url to the image in question
+ */
+ export const InspectImage = async (source: string): Promise<InspectionResults> => {
+ const { isLocal, stream, normalized: normalizedUrl } = classify(source);
+ const results = {
+ isLocal,
+ stream,
+ normalizedUrl
+ };
+ // stop here if local, since request.head() can't handle local paths, only urls on the web
+ if (isLocal) {
+ return results;
+ }
+ const metadata = (await new Promise<any>((resolve, reject) => {
+ request.head(source, async (error, res) => {
+ if (error) {
+ return reject(error);
+ }
+ resolve(res);
+ });
+ })).headers;
+ return {
+ contentSize: parseInt(metadata[size]),
+ contentType: metadata[type],
+ ...results
+ };
+ };
+
+ export const UploadInspectedImage = async (metadata: InspectionResults, filename?: string, prefix = ""): Promise<UploadInformation> => {
+ const { isLocal, stream, normalizedUrl, contentSize, contentType } = metadata;
+ const resolved = filename ? sanitize(filename) : generate(prefix, normalizedUrl);
+ let extension = path.extname(normalizedUrl) || path.extname(resolved);
+ extension && (extension = extension.toLowerCase());
+ let information: UploadInformation = {
+ mediaPaths: [],
+ fileNames: { clean: resolved },
+ contentSize,
+ contentType,
+ };
+ return new Promise<UploadInformation>(async (resolve, reject) => {
+ const resizers = [
+ { resizer: sharp().rotate(), suffix: "_o" },
+ ...Object.values(Sizes).map(size => ({
+ resizer: sharp().resize(size.width, undefined, { withoutEnlargement: true }).rotate(),
+ suffix: size.suffix
+ }))
+ ];
+ let nonVisual = false;
+ if (pngs.includes(extension)) {
+ resizers.forEach(element => element.resizer = element.resizer.png());
+ } else if (jpgs.includes(extension)) {
+ resizers.forEach(element => element.resizer = element.resizer.jpeg());
+ } else if (![...imageFormats, ...videoFormats].includes(extension.toLowerCase())) {
+ nonVisual = true;
+ }
+ if (imageFormats.includes(extension)) {
+ for (let resizer of resizers) {
+ const suffix = resizer.suffix;
+ let mediaPath: string;
+ await new Promise<void>(resolve => {
+ const filename = resolved.substring(0, resolved.length - extension.length) + suffix + extension;
+ information.mediaPaths.push(mediaPath = uploadDirectory + filename);
+ information.fileNames[suffix] = filename;
+ stream(normalizedUrl).pipe(resizer.resizer).pipe(fs.createWriteStream(mediaPath))
+ .on('close', resolve)
+ .on('error', reject);
+ });
+ }
+ }
+ if (!isLocal || nonVisual) {
+ await new Promise<void>(resolve => {
+ stream(normalizedUrl).pipe(fs.createWriteStream(uploadDirectory + resolved)).on('close', resolve);
+ });
+ }
+ resolve(information);
+ });
+ };
+
+ const classify = (url: string) => {
+ const isLocal = /Dash-Web(\\|\/)src(\\|\/)server(\\|\/)public(\\|\/)files/g.test(url);
+ return {
+ isLocal,
+ stream: isLocal ? fs.createReadStream : request,
+ normalized: isLocal ? path.normalize(url) : url
+ };
+ };
+
+ export const createIfNotExists = async (path: string) => {
+ if (await new Promise<boolean>(resolve => fs.exists(path, resolve))) {
+ return true;
+ }
+ return new Promise<boolean>(resolve => fs.mkdir(path, error => resolve(error === null)));
+ };
+
+ export const Destroy = (mediaPath: string) => new Promise<boolean>(resolve => fs.unlink(mediaPath, error => resolve(error === null)));
+
+} \ No newline at end of file
diff --git a/src/server/Message.ts b/src/server/Message.ts
index 4ec390ade..aaee143e8 100644
--- a/src/server/Message.ts
+++ b/src/server/Message.ts
@@ -1,5 +1,4 @@
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 014906054..ee9cd8a0e 100644
--- a/src/server/RouteStore.ts
+++ b/src/server/RouteStore.ts
@@ -31,6 +31,10 @@ export enum RouteStore {
// APIS
cognitiveServices = "/cognitiveservices",
- googleDocs = "/googleDocs"
+ googleDocs = "/googleDocs",
+ googlePhotosAccessToken = "/googlePhotosAccessToken",
+ googlePhotosMediaUpload = "/googlePhotosMediaUpload",
+ googlePhotosMediaDownload = "/googlePhotosMediaDownload",
+ googleDocsGet = "/googleDocsGet"
} \ No newline at end of file
diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts
index 8785cd974..c899c2ef2 100644
--- a/src/server/apis/google/GoogleApiServerUtils.ts
+++ b/src/server/apis/google/GoogleApiServerUtils.ts
@@ -1,11 +1,14 @@
-import { google, docs_v1, slides_v1 } from "googleapis";
+import { google } from "googleapis";
import { createInterface } from "readline";
import { readFile, writeFile } from "fs";
-import { OAuth2Client } from "google-auth-library";
+import { OAuth2Client, Credentials } from "google-auth-library";
import { Opt } from "../../../new_fields/Doc";
import { GlobalOptions } from "googleapis-common";
import { GaxiosResponse } from "gaxios";
-
+import request = require('request-promise');
+import * as qs from 'query-string';
+import Photos = require('googlephotos');
+import { Database } from "../../database";
/**
* Server side authentication for Google Api queries.
*/
@@ -20,6 +23,9 @@ export namespace GoogleApiServerUtils {
'presentations.readonly',
'drive',
'drive.file',
+ 'photoslibrary',
+ 'photoslibrary.appendonly',
+ 'photoslibrary.sharing'
];
export const parseBuffer = (data: Buffer) => JSON.parse(data.toString());
@@ -29,74 +35,124 @@ export namespace GoogleApiServerUtils {
Slides = "Slides"
}
-
- export interface CredentialPaths {
- credentials: string;
- token: string;
+ export interface CredentialInformation {
+ credentialsPath: string;
+ userId: string;
}
export type ApiResponse = Promise<GaxiosResponse>;
- export type ApiRouter = (endpoint: Endpoint, paramters: any) => ApiResponse;
- export type ApiHandler = (parameters: any) => ApiResponse;
+ export type ApiRouter = (endpoint: Endpoint, parameters: any) => ApiResponse;
+ export type ApiHandler = (parameters: any, methodOptions?: any) => ApiResponse;
export type Action = "create" | "retrieve" | "update";
export type Endpoint = { get: ApiHandler, create: ApiHandler, batchUpdate: ApiHandler };
export type EndpointParameters = GlobalOptions & { version: "v1" };
- export const GetEndpoint = async (sector: string, paths: CredentialPaths) => {
- return new Promise<Opt<Endpoint>>((resolve, reject) => {
- readFile(paths.credentials, (err, credentials) => {
+ export const GetEndpoint = (sector: string, paths: CredentialInformation) => {
+ return new Promise<Opt<Endpoint>>(resolve => {
+ RetrieveCredentials(paths).then(authentication => {
+ let routed: Opt<Endpoint>;
+ let parameters: EndpointParameters = { auth: authentication.client, version: "v1" };
+ switch (sector) {
+ case Service.Documents:
+ routed = google.docs(parameters).documents;
+ break;
+ case Service.Slides:
+ routed = google.slides(parameters).presentations;
+ break;
+ }
+ resolve(routed);
+ });
+ });
+ };
+
+ export const RetrieveAccessToken = (information: CredentialInformation) => {
+ return new Promise<string>((resolve, reject) => {
+ RetrieveCredentials(information).then(
+ credentials => resolve(credentials.token.access_token!),
+ error => reject(`Error: unable to authenticate Google Photos API request.\n${error}`)
+ );
+ });
+ };
+
+ export const RetrieveCredentials = (information: CredentialInformation) => {
+ return new Promise<TokenResult>((resolve, reject) => {
+ readFile(information.credentialsPath, async (err, credentials) => {
if (err) {
reject(err);
return console.log('Error loading client secret file:', err);
}
- return authorize(parseBuffer(credentials), paths.token).then(auth => {
- let routed: Opt<Endpoint>;
- let parameters: EndpointParameters = { auth, version: "v1" };
- switch (sector) {
- case Service.Documents:
- routed = google.docs(parameters).documents;
- break;
- case Service.Slides:
- routed = google.slides(parameters).presentations;
- break;
- }
- resolve(routed);
- });
+ authorize(parseBuffer(credentials), information.userId).then(resolve, reject);
});
});
};
+ export const RetrievePhotosEndpoint = (paths: CredentialInformation) => {
+ return new Promise<any>((resolve, reject) => {
+ RetrieveAccessToken(paths).then(
+ token => resolve(new Photos(token)),
+ reject
+ );
+ });
+ };
+ type TokenResult = { token: Credentials, client: OAuth2Client };
/**
* 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> {
+ export function authorize(credentials: any, userId: string): Promise<TokenResult> {
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);
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
+ return new Promise<TokenResult>((resolve, reject) => {
+ // Attempting to authorize user (${userId})
+ Database.Auxiliary.GoogleAuthenticationToken.Fetch(userId).then(token => {
+ if (!token) {
+ // No token registered, so awaiting input from user
+ return getNewToken(oAuth2Client, userId).then(resolve, reject);
}
- oAuth2Client.setCredentials(parseBuffer(token));
- resolve(oAuth2Client);
+ if (token.expiry_date! < new Date().getTime()) {
+ // Token has expired, so submitting a request for a refreshed access token
+ return refreshToken(token, client_id, client_secret, oAuth2Client, userId).then(resolve, reject);
+ }
+ // Authentication successful!
+ oAuth2Client.setCredentials(token);
+ resolve({ token, client: oAuth2Client });
});
});
}
+ const refreshEndpoint = "https://oauth2.googleapis.com/token";
+ const refreshToken = (credentials: Credentials, client_id: string, client_secret: string, oAuth2Client: OAuth2Client, userId: string) => {
+ return new Promise<TokenResult>(resolve => {
+ let headerParameters = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } };
+ let queryParameters = {
+ refreshToken: credentials.refresh_token,
+ client_id,
+ client_secret,
+ grant_type: "refresh_token"
+ };
+ let url = `${refreshEndpoint}?${qs.stringify(queryParameters)}`;
+ request.post(url, headerParameters).then(async response => {
+ let { access_token, expires_in } = JSON.parse(response);
+ const expiry_date = new Date().getTime() + (expires_in * 1000);
+ await Database.Auxiliary.GoogleAuthenticationToken.Update(userId, access_token, expiry_date);
+ credentials.access_token = access_token;
+ credentials.expiry_date = expiry_date;
+ oAuth2Client.setCredentials(credentials);
+ resolve({ token: credentials, client: 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) => {
+ function getNewToken(oAuth2Client: OAuth2Client, userId: string) {
+ return new Promise<TokenResult>((resolve, reject) => {
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES.map(relative => prefix + relative),
@@ -108,21 +164,14 @@ export namespace GoogleApiServerUtils {
});
rl.question('Enter the code from that page here: ', (code) => {
rl.close();
- oAuth2Client.getToken(code, (err, token) => {
+ oAuth2Client.getToken(code, async (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);
+ await Database.Auxiliary.GoogleAuthenticationToken.Write(userId, token);
+ resolve({ token, client: oAuth2Client });
});
});
});
diff --git a/src/server/apis/google/GooglePhotosUploadUtils.ts b/src/server/apis/google/GooglePhotosUploadUtils.ts
index 35f986250..16c4f6c3a 100644
--- a/src/server/apis/google/GooglePhotosUploadUtils.ts
+++ b/src/server/apis/google/GooglePhotosUploadUtils.ts
@@ -1,12 +1,9 @@
import request = require('request-promise');
import { GoogleApiServerUtils } from './GoogleApiServerUtils';
-import * as fs from 'fs';
-import { Utils } from '../../../Utils';
import * as path from 'path';
-import { Opt } from '../../../new_fields/Doc';
-import * as sharp from 'sharp';
-
-const uploadDirectory = path.join(__dirname, "../../public/files/");
+import { MediaItemCreationResult } from './SharedTypes';
+import { NewMediaItem } from "../../index";
+import { BatchedArray, TimeUnit } from 'array-batcher';
export namespace GooglePhotosUploadUtils {
@@ -28,12 +25,9 @@ export namespace GooglePhotosUploadUtils {
});
let Bearer: string;
- let Paths: Paths;
- export const initialize = async (paths: Paths) => {
- Paths = paths;
- const { tokenPath, credentialsPath } = paths;
- const token = await GoogleApiServerUtils.RetrieveAccessToken({ tokenPath, credentialsPath });
+ export const initialize = async (information: GoogleApiServerUtils.CredentialInformation) => {
+ const token = await GoogleApiServerUtils.RetrieveAccessToken(information);
Bearer = `Bearer ${token}`;
};
@@ -49,128 +43,39 @@ export namespace GooglePhotosUploadUtils {
uri: prepend('uploads'),
body
};
- return new Promise<any>(resolve => request(parameters, (error, _response, body) => resolve(error ? undefined : body)));
- };
-
- export const CreateMediaItems = (newMediaItems: any[], album?: { id: string }) => {
- return new Promise<any>((resolve, reject) => {
- const parameters = {
- method: 'POST',
- headers: headers('json'),
- uri: prepend('mediaItems:batchCreate'),
- body: { newMediaItems } as any,
- json: true
- };
- album && (parameters.body.albumId = album.id);
- request(parameters, (error, _response, body) => {
- if (error) {
- reject(error);
- } else {
- resolve(body);
- }
- });
- });
- };
-
-}
-
-export namespace DownloadUtils {
-
- export interface Size {
- width: number;
- suffix: string;
- }
-
- export const Sizes: { [size: string]: Size } = {
- SMALL: { width: 100, suffix: "_s" },
- MEDIUM: { width: 400, suffix: "_m" },
- LARGE: { width: 900, suffix: "_l" },
- };
-
- const png = ".png";
- const pngs = [".png", ".PNG"];
- const jpgs = [".jpg", ".JPG", ".jpeg", ".JPEG"];
- const formats = [".jpg", ".png", ".gif"];
- const size = "content-length";
- const type = "content-type";
-
- export interface UploadInformation {
- mediaPaths: string[];
- fileNames: { [key: string]: string };
- contentSize?: number;
- contentType?: string;
- }
-
- const generate = (prefix: string, url: string) => `${prefix}upload_${Utils.GenerateGuid()}${path.extname(url).toLowerCase()}`;
- const sanitize = (filename: string) => filename.replace(/\s+/g, "_");
-
- export const UploadImage = async (url: string, filename?: string, prefix = ""): Promise<Opt<UploadInformation>> => {
- const resolved = filename ? sanitize(filename) : generate(prefix, url);
- const extension = path.extname(url) || path.extname(resolved) || png;
- let information: UploadInformation = {
- mediaPaths: [],
- fileNames: { clean: resolved }
- };
- const { isLocal, stream, normalized } = classify(url);
- url = normalized;
- if (!isLocal) {
- const metadata = (await new Promise<any>((resolve, reject) => {
- request.head(url, async (error, res) => {
- if (error) {
- return reject(error);
- }
- resolve(res);
- });
- })).headers;
- information.contentSize = parseInt(metadata[size]);
- information.contentType = metadata[type];
- }
- return new Promise<UploadInformation>(async (resolve, reject) => {
- const resizers = [
- { resizer: sharp().rotate(), suffix: "_o" },
- ...Object.values(Sizes).map(size => ({
- resizer: sharp().resize(size.width, undefined, { withoutEnlargement: true }).rotate(),
- suffix: size.suffix
- }))
- ];
- if (pngs.includes(extension)) {
- resizers.forEach(element => element.resizer = element.resizer.png());
- } else if (jpgs.includes(extension)) {
- resizers.forEach(element => element.resizer = element.resizer.jpeg());
- } else if (!formats.includes(extension.toLowerCase())) {
- return reject();
+ return new Promise<any>((resolve, reject) => request(parameters, (error, _response, body) => {
+ if (error) {
+ console.log(error);
+ return reject(error);
}
- for (let resizer of resizers) {
- const suffix = resizer.suffix;
- let mediaPath: string;
- await new Promise<void>(resolve => {
- const filename = resolved.substring(0, resolved.length - extension.length) + suffix + extension;
- information.mediaPaths.push(mediaPath = uploadDirectory + filename);
- information.fileNames[suffix] = filename;
- stream(url).pipe(resizer.resizer).pipe(fs.createWriteStream(mediaPath))
- .on('close', resolve)
- .on('error', reject);
- });
- }
- resolve(information);
- });
+ resolve(body);
+ }));
};
- const classify = (url: string) => {
- const isLocal = /Dash-Web(\\|\/)src(\\|\/)server(\\|\/)public(\\|\/)files/g.test(url);
- return {
- isLocal,
- stream: isLocal ? fs.createReadStream : request,
- normalized: isLocal ? path.normalize(url) : url
- };
- };
-
- export const createIfNotExists = async (path: string) => {
- if (await new Promise<boolean>(resolve => fs.exists(path, resolve))) {
- return true;
- }
- return new Promise<boolean>(resolve => fs.mkdir(path, error => resolve(error === null)));
+ export const CreateMediaItems = async (newMediaItems: NewMediaItem[], album?: { id: string }): Promise<MediaItemCreationResult> => {
+ const newMediaItemResults = await BatchedArray.from(newMediaItems, { batchSize: 50 }).batchedMapPatientInterval(
+ { magnitude: 100, unit: TimeUnit.Milliseconds },
+ async (batch: NewMediaItem[]) => {
+ const parameters = {
+ method: 'POST',
+ headers: headers('json'),
+ uri: prepend('mediaItems:batchCreate'),
+ body: { newMediaItems: batch } as any,
+ json: true
+ };
+ album && (parameters.body.albumId = album.id);
+ return (await new Promise<MediaItemCreationResult>((resolve, reject) => {
+ request(parameters, (error, _response, body) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(body);
+ }
+ });
+ })).newMediaItemResults;
+ }
+ );
+ return { newMediaItemResults };
};
- export const Destroy = (mediaPath: string) => new Promise<boolean>(resolve => fs.unlink(mediaPath, error => resolve(error === null)));
} \ No newline at end of file
diff --git a/src/server/apis/google/SharedTypes.ts b/src/server/apis/google/SharedTypes.ts
new file mode 100644
index 000000000..9ad6130b6
--- /dev/null
+++ b/src/server/apis/google/SharedTypes.ts
@@ -0,0 +1,21 @@
+export interface NewMediaItemResult {
+ uploadToken: string;
+ status: { code: number, message: string };
+ mediaItem: MediaItem;
+}
+
+export interface MediaItem {
+ id: string;
+ description: string;
+ productUrl: string;
+ baseUrl: string;
+ mimeType: string;
+ mediaMetadata: {
+ creationTime: string;
+ width: string;
+ height: string;
+ };
+ filename: string;
+}
+
+export type MediaItemCreationResult = { newMediaItemResults: NewMediaItemResult[] }; \ No newline at end of file
diff --git a/src/server/authentication/config/passport.ts b/src/server/authentication/config/passport.ts
index 10b17de71..8b20d29b1 100644
--- a/src/server/authentication/config/passport.ts
+++ b/src/server/authentication/config/passport.ts
@@ -1,7 +1,6 @@
import * as passport from 'passport';
import * as passportLocal from 'passport-local';
-import * as mongodb from 'mongodb';
-import * as _ from "lodash";
+import _ from "lodash";
import { default as User } from '../models/user_model';
import { Request, Response, NextFunction } from "express";
import { RouteStore } from '../../RouteStore';
diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts
index 1af36fccd..24bad694d 100644
--- a/src/server/authentication/models/current_user_utils.ts
+++ b/src/server/authentication/models/current_user_utils.ts
@@ -2,7 +2,6 @@ import { action, computed, observable, runInAction } from "mobx";
import * as rp from 'request-promise';
import { DocServer } from "../../../client/DocServer";
import { Docs } from "../../../client/documents/Documents";
-import { Gateway, NorthstarSettings } from "../../../client/northstar/manager/Gateway";
import { Attribute, AttributeGroup, Catalog, Schema } from "../../../client/northstar/model/idea/idea";
import { ArrayUtil } from "../../../client/northstar/utils/ArrayUtil";
import { CollectionViewType } from "../../../client/views/collections/CollectionBaseView";
@@ -24,6 +23,9 @@ export class CurrentUserUtils {
public static get MainDocId() { return this.mainDocId; }
public static set MainDocId(id: string | undefined) { this.mainDocId = id; }
+ @observable public static GuestTarget: Doc | undefined;
+ @observable public static GuestWorkspace: Doc | undefined;
+
private static createUserDocument(id: string): Doc {
let doc = new Doc(id, true);
doc.viewType = CollectionViewType.Tree;
@@ -132,7 +134,7 @@ export class CurrentUserUtils {
this.curr_id = id;
Doc.CurrentUserEmail = email;
await rp.get(Utils.prepend(RouteStore.getUserDocumentId)).then(id => {
- if (id) {
+ if (id && id !== "guest") {
return DocServer.GetRefField(id).then(async field => {
if (field instanceof Doc) {
await this.updateUserDocument(field);
diff --git a/src/server/credentials/google_docs_credentials.json b/src/server/credentials/google_docs_credentials.json
index 8d097d363..955c5a3c1 100644
--- a/src/server/credentials/google_docs_credentials.json
+++ b/src/server/credentials/google_docs_credentials.json
@@ -1 +1,11 @@
-{"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
+{
+ "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
deleted file mode 100644
index 07c02d56c..000000000
--- a/src/server/credentials/google_docs_token.json
+++ /dev/null
@@ -1 +0,0 @@
-{"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/database.ts b/src/server/database.ts
index a7254fb0c..d2375ebd9 100644
--- a/src/server/database.ts
+++ b/src/server/database.ts
@@ -1,209 +1,295 @@
import * as mongodb from 'mongodb';
import { Transferable } from './Message';
+import { Opt } from '../new_fields/Doc';
+import { Utils, emptyFunction } from '../Utils';
+import { DashUploadUtils } from './DashUploadUtils';
+import { Credentials } from 'google-auth-library';
-export class Database {
- public static DocumentsCollection = 'documents';
- public static Instance = new Database();
- private MongoClient = mongodb.MongoClient;
- private url = 'mongodb://localhost:27017/Dash';
- private currentWrites: { [id: string]: Promise<void> } = {};
- private db?: mongodb.Db;
- private onConnect: (() => void)[] = [];
-
- constructor() {
- this.MongoClient.connect(this.url, (err, client) => {
- this.db = client.db();
- this.onConnect.forEach(fn => fn());
- });
- }
+export namespace Database {
- public update(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert = true, collectionName = Database.DocumentsCollection) {
- if (this.db) {
- let collection = this.db.collection(collectionName);
- const prom = this.currentWrites[id];
- let newProm: Promise<void>;
- const run = (): Promise<void> => {
- return new Promise<void>(resolve => {
- collection.updateOne({ _id: id }, value, { upsert }
- , (err, res) => {
- if (this.currentWrites[id] === newProm) {
- delete this.currentWrites[id];
- }
- resolve();
- callback(err, res);
- });
- });
- };
- newProm = prom ? prom.then(run) : run();
- this.currentWrites[id] = newProm;
- } else {
- this.onConnect.push(() => this.update(id, value, callback, upsert, collectionName));
- }
- }
+ class Database {
+ public static DocumentsCollection = 'documents';
+ private MongoClient = mongodb.MongoClient;
+ private url = 'mongodb://localhost:27017/Dash';
+ private currentWrites: { [id: string]: Promise<void> } = {};
+ private db?: mongodb.Db;
+ private onConnect: (() => void)[] = [];
- public replace(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert = true, collectionName = Database.DocumentsCollection) {
- if (this.db) {
- let collection = this.db.collection(collectionName);
- const prom = this.currentWrites[id];
- let newProm: Promise<void>;
- const run = (): Promise<void> => {
- return new Promise<void>(resolve => {
- collection.replaceOne({ _id: id }, value, { upsert }
- , (err, res) => {
- if (this.currentWrites[id] === newProm) {
- delete this.currentWrites[id];
- }
- resolve();
- callback(err, res);
- });
- });
- };
- newProm = prom ? prom.then(run) : run();
- this.currentWrites[id] = newProm;
- } else {
- this.onConnect.push(() => this.replace(id, value, callback, upsert, collectionName));
+ constructor() {
+ this.MongoClient.connect(this.url, (err, client) => {
+ this.db = client.db();
+ this.onConnect.forEach(fn => fn());
+ });
}
- }
- public delete(query: any, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>;
- public delete(id: string, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>;
- public delete(id: any, collectionName = Database.DocumentsCollection) {
- if (typeof id === "string") {
- id = { _id: id };
- }
- if (this.db) {
- const db = this.db;
- return new Promise(res => db.collection(collectionName).deleteMany(id, (err, result) => res(result)));
- } else {
- return new Promise(res => this.onConnect.push(() => res(this.delete(id, collectionName))));
+ public async update(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert = true, collectionName = Database.DocumentsCollection) {
+ if (this.db) {
+ let collection = this.db.collection(collectionName);
+ const prom = this.currentWrites[id];
+ let newProm: Promise<void>;
+ const run = (): Promise<void> => {
+ return new Promise<void>(resolve => {
+ collection.updateOne({ _id: id }, value, { upsert }
+ , (err, res) => {
+ if (this.currentWrites[id] === newProm) {
+ delete this.currentWrites[id];
+ }
+ resolve();
+ callback(err, res);
+ });
+ });
+ };
+ newProm = prom ? prom.then(run) : run();
+ this.currentWrites[id] = newProm;
+ return newProm;
+ } else {
+ this.onConnect.push(() => this.update(id, value, callback, upsert, collectionName));
+ }
}
- }
- public deleteAll(collectionName = Database.DocumentsCollection): Promise<any> {
- return new Promise(res => {
+ public replace(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert = true, collectionName = Database.DocumentsCollection) {
if (this.db) {
- this.db.collection(collectionName).deleteMany({}, res);
+ let collection = this.db.collection(collectionName);
+ const prom = this.currentWrites[id];
+ let newProm: Promise<void>;
+ const run = (): Promise<void> => {
+ return new Promise<void>(resolve => {
+ collection.replaceOne({ _id: id }, value, { upsert }
+ , (err, res) => {
+ if (this.currentWrites[id] === newProm) {
+ delete this.currentWrites[id];
+ }
+ resolve();
+ callback(err, res);
+ });
+ });
+ };
+ newProm = prom ? prom.then(run) : run();
+ this.currentWrites[id] = newProm;
} else {
- this.onConnect.push(() => this.db && this.db.collection(collectionName).deleteMany({}, res));
+ this.onConnect.push(() => this.replace(id, value, callback, upsert, collectionName));
}
- });
- }
+ }
- public insert(value: any, collectionName = Database.DocumentsCollection) {
- if (this.db) {
- if ("id" in value) {
- value._id = value.id;
- delete value.id;
+ public delete(query: any, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>;
+ public delete(id: string, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>;
+ public delete(id: any, collectionName = Database.DocumentsCollection) {
+ if (typeof id === "string") {
+ id = { _id: id };
+ }
+ if (this.db) {
+ const db = this.db;
+ return new Promise(res => db.collection(collectionName).deleteMany(id, (err, result) => res(result)));
+ } else {
+ return new Promise(res => this.onConnect.push(() => res(this.delete(id, collectionName))));
}
- const id = value._id;
- const collection = this.db.collection(collectionName);
- const prom = this.currentWrites[id];
- let newProm: Promise<void>;
- const run = (): Promise<void> => {
- return new Promise<void>(resolve => {
- collection.insertOne(value, (err, res) => {
- if (this.currentWrites[id] === newProm) {
- delete this.currentWrites[id];
- }
- resolve();
- });
- });
- };
- newProm = prom ? prom.then(run) : run();
- this.currentWrites[id] = newProm;
- } else {
- this.onConnect.push(() => this.insert(value, collectionName));
}
- }
- public getDocument(id: string, fn: (result?: Transferable) => void, collectionName = Database.DocumentsCollection) {
- if (this.db) {
- this.db.collection(collectionName).findOne({ _id: id }, (err, result) => {
- if (result) {
- result.id = result._id;
- delete result._id;
- fn(result);
+ public async deleteAll(collectionName = Database.DocumentsCollection, persist = true): Promise<any> {
+ return new Promise(resolve => {
+ const executor = async (database: mongodb.Db) => {
+ if (persist) {
+ await database.collection(collectionName).deleteMany({});
+ } else {
+ await database.dropCollection(collectionName);
+ }
+ resolve();
+ };
+ if (this.db) {
+ executor(this.db);
} else {
- fn(undefined);
+ this.onConnect.push(() => this.db && executor(this.db));
}
});
- } else {
- this.onConnect.push(() => this.getDocument(id, fn, collectionName));
}
- }
- public getDocuments(ids: string[], fn: (result: Transferable[]) => void, collectionName = Database.DocumentsCollection) {
- if (this.db) {
- this.db.collection(collectionName).find({ _id: { "$in": ids } }).toArray((err, docs) => {
- if (err) {
- console.log(err.message);
- console.log(err.errmsg);
+ public async insert(value: any, collectionName = Database.DocumentsCollection) {
+ if (this.db) {
+ if ("id" in value) {
+ value._id = value.id;
+ delete value.id;
}
- fn(docs.map(doc => {
- doc.id = doc._id;
- delete doc._id;
- return doc;
- }));
- });
- } else {
- this.onConnect.push(() => this.getDocuments(ids, fn, collectionName));
+ const id = value._id;
+ const collection = this.db.collection(collectionName);
+ const prom = this.currentWrites[id];
+ let newProm: Promise<void>;
+ const run = (): Promise<void> => {
+ return new Promise<void>(resolve => {
+ collection.insertOne(value, (err, res) => {
+ if (this.currentWrites[id] === newProm) {
+ delete this.currentWrites[id];
+ }
+ resolve();
+ });
+ });
+ };
+ newProm = prom ? prom.then(run) : run();
+ this.currentWrites[id] = newProm;
+ return newProm;
+ } else {
+ this.onConnect.push(() => this.insert(value, collectionName));
+ }
}
- }
- public async visit(ids: string[], fn: (result: any) => string[], collectionName = "newDocuments"): Promise<void> {
- if (this.db) {
- const visited = new Set<string>();
- while (ids.length) {
- const count = Math.min(ids.length, 1000);
- const index = ids.length - count;
- const fetchIds = ids.splice(index, count).filter(id => !visited.has(id));
- if (!fetchIds.length) {
- continue;
- }
- const docs = await new Promise<{ [key: string]: any }[]>(res => Database.Instance.getDocuments(fetchIds, res, "newDocuments"));
- for (const doc of docs) {
- const id = doc.id;
- visited.add(id);
- ids.push(...fn(doc));
+ public getDocument(id: string, fn: (result?: Transferable) => void, collectionName = Database.DocumentsCollection) {
+ if (this.db) {
+ this.db.collection(collectionName).findOne({ _id: id }, (err, result) => {
+ if (result) {
+ result.id = result._id;
+ delete result._id;
+ fn(result);
+ } else {
+ fn(undefined);
+ }
+ });
+ } else {
+ this.onConnect.push(() => this.getDocument(id, fn, collectionName));
+ }
+ }
+
+ public getDocuments(ids: string[], fn: (result: Transferable[]) => void, collectionName = Database.DocumentsCollection) {
+ if (this.db) {
+ this.db.collection(collectionName).find({ _id: { "$in": ids } }).toArray((err, docs) => {
+ if (err) {
+ console.log(err.message);
+ console.log(err.errmsg);
+ }
+ fn(docs.map(doc => {
+ doc.id = doc._id;
+ delete doc._id;
+ return doc;
+ }));
+ });
+ } else {
+ this.onConnect.push(() => this.getDocuments(ids, fn, collectionName));
+ }
+ }
+
+ public async visit(ids: string[], fn: (result: any) => string[], collectionName = "newDocuments"): Promise<void> {
+ if (this.db) {
+ const visited = new Set<string>();
+ while (ids.length) {
+ const count = Math.min(ids.length, 1000);
+ const index = ids.length - count;
+ const fetchIds = ids.splice(index, count).filter(id => !visited.has(id));
+ if (!fetchIds.length) {
+ continue;
+ }
+ const docs = await new Promise<{ [key: string]: any }[]>(res => Instance.getDocuments(fetchIds, res, "newDocuments"));
+ for (const doc of docs) {
+ const id = doc.id;
+ visited.add(id);
+ ids.push(...fn(doc));
+ }
}
+
+ } else {
+ return new Promise(res => {
+ this.onConnect.push(() => {
+ this.visit(ids, fn, collectionName);
+ res();
+ });
+ });
}
+ }
- } else {
- return new Promise(res => {
- this.onConnect.push(() => {
- this.visit(ids, fn, collectionName);
- res();
+ public query(query: { [key: string]: any }, projection?: { [key: string]: 0 | 1 }, collectionName = "newDocuments"): Promise<mongodb.Cursor> {
+ if (this.db) {
+ let cursor = this.db.collection(collectionName).find(query);
+ if (projection) {
+ cursor = cursor.project(projection);
+ }
+ return Promise.resolve<mongodb.Cursor>(cursor);
+ } else {
+ return new Promise<mongodb.Cursor>(res => {
+ this.onConnect.push(() => res(this.query(query, projection, collectionName)));
});
- });
+ }
}
- }
- public query(query: { [key: string]: any }, projection?: { [key: string]: 0 | 1 }, collectionName = "newDocuments"): Promise<mongodb.Cursor> {
- if (this.db) {
- let cursor = this.db.collection(collectionName).find(query);
- if (projection) {
- cursor = cursor.project(projection);
+ public updateMany(query: any, update: any, collectionName = "newDocuments") {
+ if (this.db) {
+ const db = this.db;
+ return new Promise<mongodb.WriteOpResult>(res => db.collection(collectionName).update(query, update, (_, result) => res(result)));
+ } else {
+ return new Promise<mongodb.WriteOpResult>(res => {
+ this.onConnect.push(() => this.updateMany(query, update, collectionName).then(res));
+ });
}
- return Promise.resolve<mongodb.Cursor>(cursor);
- } else {
- return new Promise<mongodb.Cursor>(res => {
- this.onConnect.push(() => res(this.query(query, projection, collectionName)));
- });
}
- }
- public updateMany(query: any, update: any, collectionName = "newDocuments") {
- if (this.db) {
- const db = this.db;
- return new Promise<mongodb.WriteOpResult>(res => db.collection(collectionName).update(query, update, (_, result) => res(result)));
- } else {
- return new Promise<mongodb.WriteOpResult>(res => {
- this.onConnect.push(() => this.updateMany(query, update, collectionName).then(res));
- });
+ public print() {
+ console.log("db says hi!");
}
}
- public print() {
- console.log("db says hi!");
+ export const Instance = new Database();
+
+ export namespace Auxiliary {
+
+ export enum AuxiliaryCollections {
+ GooglePhotosUploadHistory = "uploadedFromGooglePhotos"
+ }
+
+ const SanitizedCappedQuery = async (query: { [key: string]: any }, collection: string, cap: number, removeId = true) => {
+ const cursor = await Instance.query(query, undefined, collection);
+ const results = await cursor.toArray();
+ const slice = results.slice(0, Math.min(cap, results.length));
+ return removeId ? slice.map(result => {
+ delete result._id;
+ return result;
+ }) : slice;
+ };
+
+ const SanitizedSingletonQuery = async <T>(query: { [key: string]: any }, collection: string, removeId = true): Promise<Opt<T>> => {
+ const results = await SanitizedCappedQuery(query, collection, 1, removeId);
+ return results.length ? results[0] : undefined;
+ };
+
+ export const QueryUploadHistory = async (contentSize: number) => {
+ return SanitizedSingletonQuery<DashUploadUtils.UploadInformation>({ contentSize }, AuxiliaryCollections.GooglePhotosUploadHistory);
+ };
+
+ export namespace GoogleAuthenticationToken {
+
+ const GoogleAuthentication = "googleAuthentication";
+
+ export type StoredCredentials = Credentials & { _id: string };
+
+ export const Fetch = async (userId: string, removeId = true) => {
+ return SanitizedSingletonQuery<StoredCredentials>({ userId }, GoogleAuthentication, removeId);
+ };
+
+ export const Write = async (userId: string, token: any) => {
+ return Instance.insert({ userId, canAccess: [], ...token }, GoogleAuthentication);
+ };
+
+ export const Update = async (userId: string, access_token: string, expiry_date: number) => {
+ const entry = await Fetch(userId, false);
+ if (entry) {
+ const parameters = { $set: { access_token, expiry_date } };
+ return Instance.update(entry._id, parameters, emptyFunction, true, GoogleAuthentication);
+ }
+ };
+
+ export const DeleteAll = () => Instance.deleteAll(GoogleAuthentication, false);
+
+ }
+
+ export const LogUpload = async (information: DashUploadUtils.UploadInformation) => {
+ const bundle = {
+ _id: Utils.GenerateDeterministicGuid(String(information.contentSize!)),
+ ...information
+ };
+ return Instance.insert(bundle, AuxiliaryCollections.GooglePhotosUploadHistory);
+ };
+
+ export const DeleteAll = async (persist = false) => {
+ const collectionNames = Object.values(AuxiliaryCollections);
+ const pendingDeletions = collectionNames.map(name => Instance.deleteAll(name, persist));
+ return Promise.all(pendingDeletions);
+ };
+
}
-}
+
+} \ No newline at end of file
diff --git a/src/server/index.ts b/src/server/index.ts
index 524407a83..0ee00f516 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -29,14 +29,13 @@ import { RouteStore } from './RouteStore';
import v4 = require('uuid/v4');
const app = express();
const config = require('../../webpack.config');
-import { createCanvas, loadImage, Canvas } from "canvas";
+import { createCanvas } from "canvas";
const compiler = webpack(config);
const port = 1050; // default port to listen
const serverPort = 4321;
import expressFlash = require('express-flash');
import flash = require('connect-flash');
import { Search } from './Search';
-import _ = require('lodash');
import * as Archiver from 'archiver';
var AdmZip = require('adm-zip');
import * as YoutubeApi from "./apis/youtube/youtubeApiSample";
@@ -47,6 +46,11 @@ const mongoose = require('mongoose');
const probe = require("probe-image-size");
const pdf = require('pdf-parse');
var findInFiles = require('find-in-files');
+import { GooglePhotosUploadUtils } from './apis/google/GooglePhotosUploadUtils';
+import * as qs from 'query-string';
+import { Opt } from '../new_fields/Doc';
+import { DashUploadUtils } from './DashUploadUtils';
+import { BatchedArray, TimeUnit } from 'array-batcher';
const download = (url: string, dest: fs.PathLike) => request.get(url).pipe(fs.createWriteStream(dest));
let youtubeApiKey: string;
@@ -113,7 +117,9 @@ function addSecureRoute(method: Method,
...subscribers: string[]
) {
let abstracted = (req: express.Request, res: express.Response) => {
- if (req.user) {
+ let sharing = qs.parse(qs.extract(req.originalUrl), { sort: false }).sharing === "true";
+ sharing = sharing && req.originalUrl.startsWith("/doc/");
+ if (req.user || sharing) {
handler(req.user as any, res, req);
} else {
req.session!.target = req.originalUrl;
@@ -155,6 +161,13 @@ app.get("/buxton", (req, res) => {
command_line('python scraper.py', cwd).then(onResolved, tryPython3);
});
+const STATUS = {
+ OK: 200,
+ BAD_REQUEST: 400,
+ EXECUTION_ERROR: 500,
+ PERMISSION_DENIED: 403
+};
+
const command_line = (command: string, fromDirectory?: string) => {
return new Promise<string>((resolve, reject) => {
let options: ExecOptions = {};
@@ -201,7 +214,7 @@ app.get("/textsearch", async (req, res) => {
res.send([]);
return;
}
- let results = await findInFiles.find({ 'term': q, 'flags': 'ig' }, uploadDir + "text", ".txt$");
+ let results = await findInFiles.find({ 'term': q, 'flags': 'ig' }, uploadDirectory + "text", ".txt$");
let resObj: { ids: string[], numFound: number, lines: string[] } = { ids: [], numFound: 0, lines: [] };
for (var result in results) {
resObj.ids.push(path.basename(result, ".txt").replace(/upload_/, ""));
@@ -435,10 +448,10 @@ app.get("/thumbnail/:filename", (req, res) => {
let filename = req.params.filename;
let noExt = filename.substring(0, filename.length - ".png".length);
let pagenumber = parseInt(noExt.split('-')[1]);
- fs.exists(uploadDir + filename, (exists: boolean) => {
- console.log(`${uploadDir + filename} ${exists ? "exists" : "does not exist"}`);
+ fs.exists(uploadDirectory + filename, (exists: boolean) => {
+ console.log(`${uploadDirectory + filename} ${exists ? "exists" : "does not exist"}`);
if (exists) {
- let input = fs.createReadStream(uploadDir + filename);
+ let input = fs.createReadStream(uploadDirectory + filename);
probe(input, (err: any, result: any) => {
if (err) {
console.log(err);
@@ -449,7 +462,7 @@ app.get("/thumbnail/:filename", (req, res) => {
});
}
else {
- LoadPage(uploadDir + filename.substring(0, filename.length - noExt.split('-')[1].length - ".PNG".length - 1) + ".pdf", pagenumber, res);
+ LoadPage(uploadDirectory + filename.substring(0, filename.length - noExt.split('-')[1].length - ".PNG".length - 1) + ".pdf", pagenumber, res);
}
});
});
@@ -518,21 +531,20 @@ addSecureRoute(
res.sendFile(path.join(__dirname, '../../deploy/' + filename));
},
undefined,
- RouteStore.home,
- RouteStore.openDocumentWithId
+ RouteStore.home, RouteStore.openDocumentWithId
);
addSecureRoute(
Method.GET,
- (user, res) => res.send(user.userDocumentId || ""),
- undefined,
+ (user, res) => res.send(user.userDocumentId),
+ (res) => res.send(undefined),
RouteStore.getUserDocumentId,
);
addSecureRoute(
Method.GET,
- (user, res) => res.send(JSON.stringify({ id: user.id, email: user.email })),
- undefined,
+ (user, res) => { res.send(JSON.stringify({ id: user.id, email: user.email })); },
+ (res) => res.send(JSON.stringify({ id: "__guest__", email: "" })),
RouteStore.getCurrUser
);
@@ -571,74 +583,47 @@ class NodeCanvasFactory {
}
const pngTypes = [".png", ".PNG"];
-const pdfTypes = [".pdf", ".PDF"];
const jpgTypes = [".jpg", ".JPG", ".jpeg", ".JPEG"];
-const uploadDir = __dirname + "/public/files/";
+const uploadDirectory = __dirname + "/public/files/";
+interface FileResponse {
+ name: string;
+ path: string;
+ type: string;
+}
// SETTERS
app.post(
RouteStore.upload,
(req, res) => {
let form = new formidable.IncomingForm();
- form.uploadDir = uploadDir;
+ form.uploadDir = uploadDirectory;
form.keepExtensions = true;
- // let path = req.body.path;
- console.log("upload");
- form.parse(req, (err, fields, files) => {
- console.log("parsing");
- let names: string[] = [];
- for (const name in files) {
- const file = path.basename(files[name].path);
- const ext = path.extname(file);
- let resizers = [
- { resizer: sharp().rotate(), suffix: "_o" },
- { resizer: sharp().resize(100, undefined, { withoutEnlargement: true }).rotate(), suffix: "_s" },
- { resizer: sharp().resize(400, undefined, { withoutEnlargement: true }).rotate(), suffix: "_m" },
- { resizer: sharp().resize(900, undefined, { withoutEnlargement: true }).rotate(), suffix: "_l" },
- ];
- let isImage = false;
- if (pngTypes.includes(ext)) {
- resizers.forEach(element => {
- element.resizer = element.resizer.png();
- });
- isImage = true;
- } else if (jpgTypes.includes(ext)) {
- resizers.forEach(element => {
- element.resizer = element.resizer.jpeg();
- });
- isImage = true;
- }
- if (isImage) {
- resizers.forEach(resizer => {
- fs.createReadStream(uploadDir + file).pipe(resizer.resizer).pipe(fs.createWriteStream(uploadDir + file.substring(0, file.length - ext.length) + resizer.suffix + ext));
- });
- }
- if (ext.endsWith("pdf")) {
- var filePath = uploadDir + file;
+ form.parse(req, async (_err, _fields, files) => {
+ let results: FileResponse[] = [];
+ for (const key in files) {
+ const { type, path: location, name } = files[key];
+ const filename = path.basename(location);
+ if (filename.endsWith(".pdf")) {
+
+ var filePath = uploadDirectory + filename;
let dataBuffer = fs.readFileSync(filePath);
pdf(dataBuffer).then(async function (data: any) {
-
- // number of pages
- // console.log(data.numpages);
- // // number of rendered pages
- // console.log(data.numrender);
- // // PDF info
- // console.log(data.info);
- // // PDF metadata
- // console.log(data.metadata);
- // // PDF.js version
- // // check https://mozilla.github.io/pdf.js/getting_started/
- // console.log(data.version);
- // // PDF text
- // console.log(data.text);
- fs.createWriteStream(uploadDir + "text/" + file.substring(0, file.length - ext.length) + ".txt").write(data.text);
+ // // number of pages - data.numpages
+ // // number of rendered pages - data.numrender
+ // // PDF info - data.info
+ // // PDF metadata - data.metadata
+ // // PDF.js version - data.version // check https://mozilla.github.io/pdf.js/getting_started/
+ // // PDF text - data.text
+ fs.createWriteStream(uploadDirectory + "text/" + filename.substring(0, filename.length - ".pdf".length) + ".txt").write(data.text);
});
-
+ } else {
+ await DashUploadUtils.UploadImage(uploadDirectory + filename, filename).catch(() => console.log(`Unable to process ${filename}`));
}
- names.push(`/files/` + file);
+ results.push({ name, type, path: `/files/${filename}` });
+
}
- res.send(names);
+ _success(res, results);
});
}
);
@@ -652,7 +637,7 @@ addSecureRoute(
res.status(401).send("incorrect parameters specified");
return;
}
- imageDataUri.outputFile(uri, uploadDir + filename).then((savedName: string) => {
+ imageDataUri.outputFile(uri, uploadDirectory + filename).then((savedName: string) => {
const ext = path.extname(savedName);
let resizers = [
{ resizer: sharp().resize(100, undefined, { withoutEnlargement: true }), suffix: "_s" },
@@ -673,7 +658,7 @@ addSecureRoute(
}
if (isImage) {
resizers.forEach(resizer => {
- fs.createReadStream(savedName).pipe(resizer.resizer).pipe(fs.createWriteStream(uploadDir + filename + resizer.suffix + ext));
+ fs.createReadStream(savedName).pipe(resizer.resizer).pipe(fs.createWriteStream(uploadDirectory + filename + resizer.suffix + ext));
});
}
res.send("/files/" + filename + ext);
@@ -720,21 +705,29 @@ app.use(RouteStore.corsProxy, (req, res) => {
}).pipe(res);
});
-app.get(RouteStore.delete, (req, res) => {
- if (release) {
- res.send("no");
- return;
- }
- deleteFields().then(() => res.redirect(RouteStore.home));
-});
+addSecureRoute(
+ Method.GET,
+ (user, res, req) => {
+ if (release) {
+ return _permission_denied(res, deletionPermissionError);
+ }
+ deleteFields().then(() => res.redirect(RouteStore.home));
+ },
+ undefined,
+ RouteStore.delete
+);
-app.get(RouteStore.deleteAll, (req, res) => {
- if (release) {
- res.send("no");
- return;
- }
- deleteAll().then(() => res.redirect(RouteStore.home));
-});
+addSecureRoute(
+ Method.GET,
+ (_user, res, _req) => {
+ if (release) {
+ return _permission_denied(res, deletionPermissionError);
+ }
+ deleteAll().then(() => res.redirect(RouteStore.home));
+ },
+ undefined,
+ RouteStore.deleteAll
+);
app.use(wdm(compiler, { publicPath: config.output.publicPath }));
@@ -840,8 +833,7 @@ 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");
+const credentialsPath = path.join(__dirname, "./credentials/google_docs_credentials.json");
const EndpointHandlerMap = new Map<GoogleApiServerUtils.Action, GoogleApiServerUtils.ApiRouter>([
["create", (api, params) => api.create(params)],
@@ -852,7 +844,7 @@ const EndpointHandlerMap = new Map<GoogleApiServerUtils.Action, GoogleApiServerU
app.post(RouteStore.googleDocs + "/:sector/:action", (req, res) => {
let sector: GoogleApiServerUtils.Service = req.params.sector as GoogleApiServerUtils.Service;
let action: GoogleApiServerUtils.Action = req.params.action as GoogleApiServerUtils.Action;
- GoogleApiServerUtils.GetEndpoint(GoogleApiServerUtils.Service[sector], { credentials, token }).then(endpoint => {
+ GoogleApiServerUtils.GetEndpoint(GoogleApiServerUtils.Service[sector], { credentialsPath, userId: req.headers.userId as string }).then(endpoint => {
let handler = EndpointHandlerMap.get(action);
if (endpoint && handler) {
let execute = handler(endpoint, req.body).then(
@@ -866,6 +858,134 @@ app.post(RouteStore.googleDocs + "/:sector/:action", (req, res) => {
});
});
+app.get(RouteStore.googlePhotosAccessToken, (req, res) => GoogleApiServerUtils.RetrieveAccessToken({ credentialsPath, userId: req.header("userId")! }).then(token => res.send(token)));
+
+const tokenError = "Unable to successfully upload bytes for all images!";
+const mediaError = "Unable to convert all uploaded bytes to media items!";
+const userIdError = "Unable to parse the identification of the user!";
+
+export interface NewMediaItem {
+ description: string;
+ simpleMediaItem: {
+ uploadToken: string;
+ };
+}
+
+app.post(RouteStore.googlePhotosMediaUpload, async (req, res) => {
+ const { media } = req.body;
+ const userId = req.header("userId");
+
+ if (!userId) {
+ return _error(res, userIdError);
+ }
+
+ await GooglePhotosUploadUtils.initialize({ credentialsPath, userId });
+
+ let failed = 0;
+
+ const newMediaItems = await BatchedArray.from<GooglePhotosUploadUtils.MediaInput>(media, { batchSize: 25 }).batchedMapPatientInterval(
+ { magnitude: 100, unit: TimeUnit.Milliseconds },
+ async (batch: GooglePhotosUploadUtils.MediaInput[]) => {
+ const newMediaItems: NewMediaItem[] = [];
+ for (let element of batch) {
+ const uploadToken = await GooglePhotosUploadUtils.DispatchGooglePhotosUpload(element.url);
+ if (!uploadToken) {
+ failed++;
+ } else {
+ newMediaItems.push({
+ description: element.description,
+ simpleMediaItem: { uploadToken }
+ });
+ }
+ }
+ return newMediaItems;
+ }
+ );
+
+ if (failed) {
+ return _error(res, tokenError);
+ }
+
+ GooglePhotosUploadUtils.CreateMediaItems(newMediaItems, req.body.album).then(
+ result => _success(res, result.newMediaItemResults),
+ error => _error(res, mediaError, error)
+ );
+});
+
+interface MediaItem {
+ baseUrl: string;
+ filename: string;
+}
+const prefix = "google_photos_";
+
+const downloadError = "Encountered an error while executing downloads.";
+const requestError = "Unable to execute download: the body's media items were malformed.";
+const deletionPermissionError = "Cannot perform specialized delete outside of the development environment!";
+
+app.get("/deleteWithAux", async (_req, res) => {
+ if (release) {
+ return _permission_denied(res, deletionPermissionError);
+ }
+ await Database.Auxiliary.DeleteAll();
+ res.redirect(RouteStore.delete);
+});
+
+app.get("/deleteWithGoogleCredentials", async (req, res) => {
+ if (release) {
+ return _permission_denied(res, deletionPermissionError);
+ }
+ await Database.Auxiliary.GoogleAuthenticationToken.DeleteAll();
+ res.redirect(RouteStore.delete);
+});
+
+const UploadError = (count: number) => `Unable to upload ${count} images to Dash's server`;
+app.post(RouteStore.googlePhotosMediaDownload, async (req, res) => {
+ const contents: { mediaItems: MediaItem[] } = req.body;
+ let failed = 0;
+ if (contents) {
+ const completed: Opt<DashUploadUtils.UploadInformation>[] = [];
+ for (let item of contents.mediaItems) {
+ const { contentSize, ...attributes } = await DashUploadUtils.InspectImage(item.baseUrl);
+ const found: Opt<DashUploadUtils.UploadInformation> = await Database.Auxiliary.QueryUploadHistory(contentSize!);
+ if (!found) {
+ const upload = await DashUploadUtils.UploadInspectedImage({ contentSize, ...attributes }, item.filename, prefix).catch(error => _error(res, downloadError, error));
+ if (upload) {
+ completed.push(upload);
+ await Database.Auxiliary.LogUpload(upload);
+ } else {
+ failed++;
+ }
+ } else {
+ completed.push(found);
+ }
+ }
+ if (failed) {
+ return _error(res, UploadError(failed));
+ }
+ return _success(res, completed);
+ }
+ _invalid(res, requestError);
+});
+
+const _error = (res: Response, message: string, error?: any) => {
+ res.statusMessage = message;
+ res.status(STATUS.EXECUTION_ERROR).send(error);
+};
+
+const _success = (res: Response, body: any) => {
+ res.status(STATUS.OK).send(body);
+};
+
+const _invalid = (res: Response, message: string) => {
+ res.statusMessage = message;
+ res.status(STATUS.BAD_REQUEST).send();
+};
+
+const _permission_denied = (res: Response, message: string) => {
+ res.statusMessage = message;
+ res.status(STATUS.BAD_REQUEST).send("Permission Denied!");
+};
+
const suffixMap: { [type: string]: (string | [string, string | ((json: any) => any)]) } = {
"number": "_n",
"string": "_t",
diff --git a/src/server/updateSearch.ts b/src/server/updateSearch.ts
deleted file mode 100644
index 906b795f1..000000000
--- a/src/server/updateSearch.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Database } from "./database";
-import { Cursor } from "mongodb";
-import { Search } from "./Search";
-import pLimit from 'p-limit';
-
-const suffixMap: { [type: string]: (string | [string, string | ((json: any) => any)]) } = {
- "number": "_n",
- "string": "_t",
- "boolean": "_b",
- // "image": ["_t", "url"],
- "video": ["_t", "url"],
- "pdf": ["_t", "url"],
- "audio": ["_t", "url"],
- "web": ["_t", "url"],
- "date": ["_d", value => new Date(value.date).toISOString()],
- "proxy": ["_i", "fieldId"],
- "list": ["_l", list => {
- const results = [];
- for (const value of list.fields) {
- const term = ToSearchTerm(value);
- if (term) {
- results.push(term.value);
- }
- }
- return results.length ? results : null;
- }]
-};
-
-function ToSearchTerm(val: any): { suffix: string, value: any } | undefined {
- if (val === null || val === undefined) {
- return;
- }
- const type = val.__type || typeof val;
- let suffix = suffixMap[type];
- if (!suffix) {
- return;
- }
-
- if (Array.isArray(suffix)) {
- const accessor = suffix[1];
- if (typeof accessor === "function") {
- val = accessor(val);
- } else {
- val = val[accessor];
- }
- suffix = suffix[0];
- }
-
- return { suffix, value: val };
-}
-
-function getSuffix(value: string | [string, any]): string {
- return typeof value === "string" ? value : value[0];
-}
-
-const limit = pLimit(5);
-async function update() {
- // await new Promise(res => setTimeout(res, 5));
- console.log("update");
- await Search.Instance.clear();
- const cursor = await Database.Instance.query({});
- console.log("Cleared");
- const updates: any[] = [];
- let numDocs = 0;
- function updateDoc(doc: any) {
- numDocs++;
- if ((numDocs % 50) === 0) {
- console.log("updateDoc " + numDocs);
- }
- // console.log("doc " + numDocs);
- if (doc.__type !== "Doc") {
- return;
- }
- const fields = doc.fields;
- if (!fields) {
- return;
- }
- const update: any = { id: doc._id };
- let dynfield = false;
- for (const key in fields) {
- const value = fields[key];
- const term = ToSearchTerm(value);
- if (term !== undefined) {
- let { suffix, value } = term;
- update[key + suffix] = value;
- dynfield = true;
- }
- }
- if (dynfield) {
- updates.push(update);
- // console.log(updates.length);
- }
- }
- await cursor.forEach(updateDoc);
- console.log(`Updating ${updates.length} documents`);
- const result = await Search.Instance.updateDocuments(updates);
- try {
- console.log(JSON.parse(result).responseHeader.status);
- } catch {
- console.log("Error:");
- // console.log(updates[i]);
- console.log(result);
- console.log("\n");
- }
- // for (let i = 0; i < updates.length; i++) {
- // console.log(i);
- // const result = await Search.Instance.updateDocument(updates[i]);
- // try {
- // console.log(JSON.parse(result).responseHeader.status);
- // } catch {
- // console.log("Error:");
- // console.log(updates[i]);
- // console.log(result);
- // console.log("\n");
- // }
- // }
- // await Promise.all(updates.map(update => {
- // return limit(() => Search.Instance.updateDocument(update));
- // }));
- cursor.close();
-}
-
-update(); \ No newline at end of file
diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts
index 7939ae8be..36d828fdb 100644
--- a/src/typings/index.d.ts
+++ b/src/typings/index.d.ts
@@ -1,322 +1,324 @@
/// <reference types="node" />
-declare module '@react-pdf/renderer' {
- import * as React from 'react';
-
- namespace ReactPDF {
- interface Style {
- [property: string]: any;
- }
- interface Styles {
- [key: string]: Style;
- }
- type Orientation = 'portrait' | 'landscape';
-
- interface DocumentProps {
- title?: string;
- author?: string;
- subject?: string;
- keywords?: string;
- creator?: string;
- producer?: string;
- onRender?: () => any;
- }
-
- /**
- * This component represent the PDF document itself. It must be the root
- * of your tree element structure, and under no circumstances should it be
- * used as children of another react-pdf component. In addition, it should
- * only have childs of type <Page />.
- */
- class Document extends React.Component<DocumentProps> { }
-
- interface NodeProps {
- style?: Style | Style[];
- /**
- * Render component in all wrapped pages.
- * @see https://react-pdf.org/advanced#fixed-components
- */
- fixed?: boolean;
- /**
- * Force the wrapping algorithm to start a new page when rendering the
- * element.
- * @see https://react-pdf.org/advanced#page-breaks
- */
- break?: boolean;
- }
-
- interface PageProps extends NodeProps {
- /**
- * Enable page wrapping for this page.
- * @see https://react-pdf.org/components#page-wrapping
- */
- wrap?: boolean;
- debug?: boolean;
- size?: string | [number, number] | { width: number; height: number };
- orientation?: Orientation;
- ruler?: boolean;
- rulerSteps?: number;
- verticalRuler?: boolean;
- verticalRulerSteps?: number;
- horizontalRuler?: boolean;
- horizontalRulerSteps?: number;
- ref?: Page;
- }
-
- /**
- * Represents single page inside the PDF document, or a subset of them if
- * using the wrapping feature. A <Document /> can contain as many pages as
- * you want, but ensure not rendering a page inside any component besides
- * Document.
- */
- class Page extends React.Component<PageProps> { }
-
- interface ViewProps extends NodeProps {
- /**
- * Enable/disable page wrapping for element.
- * @see https://react-pdf.org/components#page-wrapping
- */
- wrap?: boolean;
- debug?: boolean;
- render?: (props: { pageNumber: number }) => React.ReactNode;
- children?: React.ReactNode;
- }
-
- /**
- * The most fundamental component for building a UI and is designed to be
- * nested inside other views and can have 0 to many children.
- */
- class View extends React.Component<ViewProps> { }
-
- interface ImageProps extends NodeProps {
- debug?: boolean;
- src: string | { data: Buffer; format: 'png' | 'jpg' };
- cache?: boolean;
- }
-
- /**
- * A React component for displaying network or local (Node only) JPG or
- * PNG images, as well as base64 encoded image strings.
- */
- class Image extends React.Component<ImageProps> { }
-
- interface TextProps extends NodeProps {
- /**
- * Enable/disable page wrapping for element.
- * @see https://react-pdf.org/components#page-wrapping
- */
- wrap?: boolean;
- debug?: boolean;
- render?: (
- props: { pageNumber: number; totalPages: number },
- ) => React.ReactNode;
- children?: React.ReactNode;
- /**
- * How much hyphenated breaks should be avoided.
- */
- hyphenationCallback?: number;
- }
-
- /**
- * A React component for displaying text. Text supports nesting of other
- * Text or Link components to create inline styling.
- */
- class Text extends React.Component<TextProps> { }
-
- interface LinkProps extends NodeProps {
- /**
- * Enable/disable page wrapping for element.
- * @see https://react-pdf.org/components#page-wrapping
- */
- wrap?: boolean;
- debug?: boolean;
- src: string;
- children?: React.ReactNode;
- }
+declare module 'googlephotos';
- /**
- * A React component for displaying an hyperlink. Link’s can be nested
- * inside a Text component, or being inside any other valid primitive.
- */
- class Link extends React.Component<LinkProps> { }
-
- interface NoteProps extends NodeProps {
- children: string;
- }
-
- class Note extends React.Component<NoteProps> { }
-
- interface BlobProviderParams {
- blob: Blob | null;
- url: string | null;
- loading: boolean;
- error: Error | null;
- }
- interface BlobProviderProps {
- document: React.ReactElement<DocumentProps>;
- children: (params: BlobProviderParams) => React.ReactNode;
- }
-
- /**
- * Easy and declarative way of getting document's blob data without
- * showing it on screen.
- * @see https://react-pdf.org/advanced#on-the-fly-rendering
- * @platform web
- */
- class BlobProvider extends React.Component<BlobProviderProps> { }
-
- interface PDFViewerProps {
- width?: number;
- height?: number;
- style?: Style | Style[];
- className?: string;
- children?: React.ReactElement<DocumentProps>;
- }
-
- /**
- * Iframe PDF viewer for client-side generated documents.
- * @platform web
- */
- class PDFViewer extends React.Component<PDFViewerProps> { }
-
- interface PDFDownloadLinkProps {
- document: React.ReactElement<DocumentProps>;
- fileName?: string;
- style?: Style | Style[];
- className?: string;
- children?:
- | React.ReactNode
- | ((params: BlobProviderParams) => React.ReactNode);
+declare module '@react-pdf/renderer' {
+ import * as React from 'react';
+
+ namespace ReactPDF {
+ interface Style {
+ [property: string]: any;
+ }
+ interface Styles {
+ [key: string]: Style;
+ }
+ type Orientation = 'portrait' | 'landscape';
+
+ interface DocumentProps {
+ title?: string;
+ author?: string;
+ subject?: string;
+ keywords?: string;
+ creator?: string;
+ producer?: string;
+ onRender?: () => any;
+ }
+
+ /**
+ * This component represent the PDF document itself. It must be the root
+ * of your tree element structure, and under no circumstances should it be
+ * used as children of another react-pdf component. In addition, it should
+ * only have childs of type <Page />.
+ */
+ class Document extends React.Component<DocumentProps> { }
+
+ interface NodeProps {
+ style?: Style | Style[];
+ /**
+ * Render component in all wrapped pages.
+ * @see https://react-pdf.org/advanced#fixed-components
+ */
+ fixed?: boolean;
+ /**
+ * Force the wrapping algorithm to start a new page when rendering the
+ * element.
+ * @see https://react-pdf.org/advanced#page-breaks
+ */
+ break?: boolean;
+ }
+
+ interface PageProps extends NodeProps {
+ /**
+ * Enable page wrapping for this page.
+ * @see https://react-pdf.org/components#page-wrapping
+ */
+ wrap?: boolean;
+ debug?: boolean;
+ size?: string | [number, number] | { width: number; height: number };
+ orientation?: Orientation;
+ ruler?: boolean;
+ rulerSteps?: number;
+ verticalRuler?: boolean;
+ verticalRulerSteps?: number;
+ horizontalRuler?: boolean;
+ horizontalRulerSteps?: number;
+ ref?: Page;
+ }
+
+ /**
+ * Represents single page inside the PDF document, or a subset of them if
+ * using the wrapping feature. A <Document /> can contain as many pages as
+ * you want, but ensure not rendering a page inside any component besides
+ * Document.
+ */
+ class Page extends React.Component<PageProps> { }
+
+ interface ViewProps extends NodeProps {
+ /**
+ * Enable/disable page wrapping for element.
+ * @see https://react-pdf.org/components#page-wrapping
+ */
+ wrap?: boolean;
+ debug?: boolean;
+ render?: (props: { pageNumber: number }) => React.ReactNode;
+ children?: React.ReactNode;
+ }
+
+ /**
+ * The most fundamental component for building a UI and is designed to be
+ * nested inside other views and can have 0 to many children.
+ */
+ class View extends React.Component<ViewProps> { }
+
+ interface ImageProps extends NodeProps {
+ debug?: boolean;
+ src: string | { data: Buffer; format: 'png' | 'jpg' };
+ cache?: boolean;
+ }
+
+ /**
+ * A React component for displaying network or local (Node only) JPG or
+ * PNG images, as well as base64 encoded image strings.
+ */
+ class Image extends React.Component<ImageProps> { }
+
+ interface TextProps extends NodeProps {
+ /**
+ * Enable/disable page wrapping for element.
+ * @see https://react-pdf.org/components#page-wrapping
+ */
+ wrap?: boolean;
+ debug?: boolean;
+ render?: (
+ props: { pageNumber: number; totalPages: number },
+ ) => React.ReactNode;
+ children?: React.ReactNode;
+ /**
+ * How much hyphenated breaks should be avoided.
+ */
+ hyphenationCallback?: number;
+ }
+
+ /**
+ * A React component for displaying text. Text supports nesting of other
+ * Text or Link components to create inline styling.
+ */
+ class Text extends React.Component<TextProps> { }
+
+ interface LinkProps extends NodeProps {
+ /**
+ * Enable/disable page wrapping for element.
+ * @see https://react-pdf.org/components#page-wrapping
+ */
+ wrap?: boolean;
+ debug?: boolean;
+ src: string;
+ children?: React.ReactNode;
+ }
+
+ /**
+ * A React component for displaying an hyperlink. Link’s can be nested
+ * inside a Text component, or being inside any other valid primitive.
+ */
+ class Link extends React.Component<LinkProps> { }
+
+ interface NoteProps extends NodeProps {
+ children: string;
+ }
+
+ class Note extends React.Component<NoteProps> { }
+
+ interface BlobProviderParams {
+ blob: Blob | null;
+ url: string | null;
+ loading: boolean;
+ error: Error | null;
+ }
+ interface BlobProviderProps {
+ document: React.ReactElement<DocumentProps>;
+ children: (params: BlobProviderParams) => React.ReactNode;
+ }
+
+ /**
+ * Easy and declarative way of getting document's blob data without
+ * showing it on screen.
+ * @see https://react-pdf.org/advanced#on-the-fly-rendering
+ * @platform web
+ */
+ class BlobProvider extends React.Component<BlobProviderProps> { }
+
+ interface PDFViewerProps {
+ width?: number;
+ height?: number;
+ style?: Style | Style[];
+ className?: string;
+ children?: React.ReactElement<DocumentProps>;
+ }
+
+ /**
+ * Iframe PDF viewer for client-side generated documents.
+ * @platform web
+ */
+ class PDFViewer extends React.Component<PDFViewerProps> { }
+
+ interface PDFDownloadLinkProps {
+ document: React.ReactElement<DocumentProps>;
+ fileName?: string;
+ style?: Style | Style[];
+ className?: string;
+ children?:
+ | React.ReactNode
+ | ((params: BlobProviderParams) => React.ReactNode);
+ }
+
+ /**
+ * Anchor tag to enable generate and download PDF documents on the fly.
+ * @see https://react-pdf.org/advanced#on-the-fly-rendering
+ * @platform web
+ */
+ class PDFDownloadLink extends React.Component<PDFDownloadLinkProps> { }
+
+ interface EmojiSource {
+ url: string;
+ format: string;
+ }
+ interface RegisteredFont {
+ src: string;
+ loaded: boolean;
+ loading: boolean;
+ data: any;
+ [key: string]: any;
+ }
+ type HyphenationCallback = (
+ words: string[],
+ glyphString: { [key: string]: any },
+ ) => string[];
+
+ const Font: {
+ register: (
+ src: string,
+ options: { family: string;[key: string]: any },
+ ) => void;
+ getEmojiSource: () => EmojiSource;
+ getRegisteredFonts: () => string[];
+ registerEmojiSource: (emojiSource: EmojiSource) => void;
+ registerHyphenationCallback: (
+ hyphenationCallback: HyphenationCallback,
+ ) => void;
+ getHyphenationCallback: () => HyphenationCallback;
+ getFont: (fontFamily: string) => RegisteredFont | undefined;
+ load: (
+ fontFamily: string,
+ document: React.ReactElement<DocumentProps>,
+ ) => Promise<void>;
+ clear: () => void;
+ reset: () => void;
+ };
+
+ const StyleSheet: {
+ hairlineWidth: number;
+ create: <TStyles>(styles: TStyles) => TStyles;
+ resolve: (
+ style: Style,
+ container: {
+ width: number;
+ height: number;
+ orientation: Orientation;
+ },
+ ) => Style;
+ flatten: (...styles: Style[]) => Style;
+ absoluteFillObject: {
+ position: 'absolute';
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ };
+ };
+
+ const version: any;
+
+ const PDFRenderer: any;
+
+ const createInstance: (
+ element: {
+ type: string;
+ props: { [key: string]: any };
+ },
+ root?: any,
+ ) => any;
+
+ const pdf: (
+ document: React.ReactElement<DocumentProps>,
+ ) => {
+ isDirty: () => boolean;
+ updateContainer: (document: React.ReactElement<any>) => void;
+ toBuffer: () => NodeJS.ReadableStream;
+ toBlob: () => Blob;
+ toString: () => string;
+ };
+
+ const renderToStream: (
+ document: React.ReactElement<DocumentProps>,
+ ) => NodeJS.ReadableStream;
+
+ const renderToFile: (
+ document: React.ReactElement<DocumentProps>,
+ filePath: string,
+ callback?: (output: NodeJS.ReadableStream, filePath: string) => any,
+ ) => Promise<NodeJS.ReadableStream>;
+
+ const render: typeof renderToFile;
}
- /**
- * Anchor tag to enable generate and download PDF documents on the fly.
- * @see https://react-pdf.org/advanced#on-the-fly-rendering
- * @platform web
- */
- class PDFDownloadLink extends React.Component<PDFDownloadLinkProps> { }
-
- interface EmojiSource {
- url: string;
- format: string;
- }
- interface RegisteredFont {
- src: string;
- loaded: boolean;
- loading: boolean;
- data: any;
- [key: string]: any;
- }
- type HyphenationCallback = (
- words: string[],
- glyphString: { [key: string]: any },
- ) => string[];
-
- const Font: {
- register: (
- src: string,
- options: { family: string;[key: string]: any },
- ) => void;
- getEmojiSource: () => EmojiSource;
- getRegisteredFonts: () => string[];
- registerEmojiSource: (emojiSource: EmojiSource) => void;
- registerHyphenationCallback: (
- hyphenationCallback: HyphenationCallback,
- ) => void;
- getHyphenationCallback: () => HyphenationCallback;
- getFont: (fontFamily: string) => RegisteredFont | undefined;
- load: (
- fontFamily: string,
- document: React.ReactElement<DocumentProps>,
- ) => Promise<void>;
- clear: () => void;
- reset: () => void;
+ const Document: typeof ReactPDF.Document;
+ const Page: typeof ReactPDF.Page;
+ const View: typeof ReactPDF.View;
+ const Image: typeof ReactPDF.Image;
+ const Text: typeof ReactPDF.Text;
+ const Link: typeof ReactPDF.Link;
+ const Note: typeof ReactPDF.Note;
+ const Font: typeof ReactPDF.Font;
+ const StyleSheet: typeof ReactPDF.StyleSheet;
+ const createInstance: typeof ReactPDF.createInstance;
+ const PDFRenderer: typeof ReactPDF.PDFRenderer;
+ const version: typeof ReactPDF.version;
+ const pdf: typeof ReactPDF.pdf;
+
+ export default ReactPDF;
+ export {
+ Document,
+ Page,
+ View,
+ Image,
+ Text,
+ Link,
+ Note,
+ Font,
+ StyleSheet,
+ createInstance,
+ PDFRenderer,
+ version,
+ pdf,
};
-
- const StyleSheet: {
- hairlineWidth: number;
- create: <TStyles>(styles: TStyles) => TStyles;
- resolve: (
- style: Style,
- container: {
- width: number;
- height: number;
- orientation: Orientation;
- },
- ) => Style;
- flatten: (...styles: Style[]) => Style;
- absoluteFillObject: {
- position: 'absolute';
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- };
- };
-
- const version: any;
-
- const PDFRenderer: any;
-
- const createInstance: (
- element: {
- type: string;
- props: { [key: string]: any };
- },
- root?: any,
- ) => any;
-
- const pdf: (
- document: React.ReactElement<DocumentProps>,
- ) => {
- isDirty: () => boolean;
- updateContainer: (document: React.ReactElement<any>) => void;
- toBuffer: () => NodeJS.ReadableStream;
- toBlob: () => Blob;
- toString: () => string;
- };
-
- const renderToStream: (
- document: React.ReactElement<DocumentProps>,
- ) => NodeJS.ReadableStream;
-
- const renderToFile: (
- document: React.ReactElement<DocumentProps>,
- filePath: string,
- callback?: (output: NodeJS.ReadableStream, filePath: string) => any,
- ) => Promise<NodeJS.ReadableStream>;
-
- const render: typeof renderToFile;
- }
-
- const Document: typeof ReactPDF.Document;
- const Page: typeof ReactPDF.Page;
- const View: typeof ReactPDF.View;
- const Image: typeof ReactPDF.Image;
- const Text: typeof ReactPDF.Text;
- const Link: typeof ReactPDF.Link;
- const Note: typeof ReactPDF.Note;
- const Font: typeof ReactPDF.Font;
- const StyleSheet: typeof ReactPDF.StyleSheet;
- const createInstance: typeof ReactPDF.createInstance;
- const PDFRenderer: typeof ReactPDF.PDFRenderer;
- const version: typeof ReactPDF.version;
- const pdf: typeof ReactPDF.pdf;
-
- export default ReactPDF;
- export {
- Document,
- Page,
- View,
- Image,
- Text,
- Link,
- Note,
- Font,
- StyleSheet,
- createInstance,
- PDFRenderer,
- version,
- pdf,
- };
} \ No newline at end of file