diff options
Diffstat (limited to 'src')
30 files changed, 1715 insertions, 881 deletions
diff --git a/src/Utils.ts b/src/Utils.ts index 3921a49c3..ec2bee9bf 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -37,6 +37,11 @@ export class Utils { public static prepend(extension: string): string { return window.location.origin + extension; } + + public static fileUrl(filename: string): string { + return this.prepend(`/file/${filename}`); + } + public static CorsProxy(url: string): string { return this.prepend(RouteStore.corsProxy + "/") + encodeURIComponent(url); } diff --git a/src/client/apis/google_docs/GoogleApiClientUtils.ts b/src/client/apis/google_docs/GoogleApiClientUtils.ts index 798886def..828d4451a 100644 --- a/src/client/apis/google_docs/GoogleApiClientUtils.ts +++ b/src/client/apis/google_docs/GoogleApiClientUtils.ts @@ -3,111 +3,130 @@ 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 { RichTextField } from "../../../new_fields/RichTextField"; 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.runs.map(run => run.content).join("")).join(""); + text = text.substring(0, text.length - 1); + removeNewlines && text.ReplaceAll("\n", ""); + return { text, paragraphs }; + }; + + export type DeconstructedParagraph = { runs: docs_v1.Schema$TextRun[], bullet: Opt<number> }; + 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: docs_v1.Schema$TextRun[] = []; + let bullet: Opt<number>; + if (element.paragraph) { + if (element.paragraph.elements) { + for (const inner of element.paragraph.elements) { + if (inner && inner.textRun) { + let run = inner.textRun; + (run.content || !filterEmpty) && runs.push(inner.textRun); + } } } + if (element.paragraph.bullet) { + bullet = element.paragraph.bullet.nestingLevel || 0; + } } + (runs.length || !filterEmpty) && fragments.push({ 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 +149,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 +176,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 +234,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 +244,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..700c0401a --- /dev/null +++ b/src/client/apis/google_docs/GooglePhotosClientUtils.ts @@ -0,0 +1,328 @@ +import { PostToServer, 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 { List } from "../../../new_fields/List"; +import { listSpec } from "../../../new_fields/Schema"; + +export namespace GooglePhotos { + + const endpoint = async () => { + const getToken = Utils.prepend(RouteStore.googlePhotosAccessToken); + const token = await (await fetch(getToken)).text(); + return new Photos(token); + }; + + 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 } = 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 = images[i]; + const mediaItem = mediaItems[i]; + image.googlePhotosId = mediaItem.id; + image.googlePhotosUrl = mediaItem.baseUrl || mediaItem.productUrl; + idMapping[mediaItem.id] = image; + } + collection.googlePhotosIdMapping = idMapping; + if (tag) { + await Query.TagChildImages(collection); + } + collection.albumId = id; + 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.Search(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 = ", "; + 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); + 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 Search({ 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((a, b) => (a < b) ? -1 : (a > b ? 1 : 0)).join(delimiter); + } + }); + + }; + + 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 Search = 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 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 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[] = []; + sources.forEach(source => { + const data = Cast(Doc.GetProto(source).data, ImageField); + if (!data) { + return; + } + const url = data.url.href; + const description = parseDescription(source, descriptionKey); + media.push({ url, description }); + }); + if (media.length) { + const uploads: NewMediaItemResult[] = await PostToServer(RouteStore.googlePhotosMediaUpload, { media, album }); + return uploads; + } + }; + + const parseDescription = (document: Doc, descriptionKey: string) => { + let description: string = Utils.prepend("/doc/" + document[Id]); + 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 602a7f9ad..5dd945c16 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -21,7 +21,7 @@ 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 } from "../../new_fields/Doc"; -import { OmitKeys, JSONUtils } from "../../Utils"; +import { OmitKeys, JSONUtils, Utils } from "../../Utils"; import { ImageField, VideoField, AudioField, PdfField, WebField, YoutubeField } from "../../new_fields/URLField"; import { HtmlField } from "../../new_fields/HtmlField"; import { List } from "../../new_fields/List"; @@ -333,7 +333,12 @@ 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}`; + } + requestImageSize(target) .then((size: any) => { let aspect = size.height / size.width; if (!inst.proto!.nativeWidth) { @@ -507,10 +512,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); diff --git a/src/client/northstar/utils/Extensions.ts b/src/client/northstar/utils/Extensions.ts index df14d4da0..ab9384f1f 100644 --- a/src/client/northstar/utils/Extensions.ts +++ b/src/client/northstar/utils/Extensions.ts @@ -1,6 +1,8 @@ interface String { ReplaceAll(toReplace: string, replacement: string): string; Truncate(length: number, replacement: string): String; + removeTrailingNewlines(): string; + hasNewline(): boolean; } String.prototype.ReplaceAll = function (toReplace: string, replacement: string): string { diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx index 75b0b52a7..b0bbb5462 100644 --- a/src/client/util/Import & Export/DirectoryImportBox.tsx +++ b/src/client/util/Import & Export/DirectoryImportBox.tsx @@ -18,8 +18,14 @@ 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"; const unsupported = ["text/html", "text/plain"]; +interface FileResponse { + name: string; + path: string; + type: string; +} @observer export default class DirectoryImportBox extends React.Component<FieldViewProps> { @@ -68,13 +74,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> handleSelection = async (e: React.ChangeEvent<HTMLInputElement>) => { runInAction(() => this.uploading = true); - 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++) { @@ -87,32 +92,39 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> let sizes = []; let modifiedDates = []; - for (let uploaded_file of validated) { + let i = 0; + const uploads: FileResponse[] = []; + const batchSize = 15; + + while (i < validated.length) { + const cap = Math.min(validated.length, i + batchSize); let formData = new FormData(); - formData.append('file', uploaded_file); - let dropFileName = uploaded_file ? uploaded_file.name : "-empty-"; - let type = uploaded_file.type; - - sizes.push(uploaded_file.size); - modifiedDates.push(uploaded_file.lastModified); - - runInAction(() => this.remaining++); - - 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--); - }); - })); - }); - promises.push(prom); + const batch = validated.slice(i, cap); + + sizes.push(...batch.map(file => file.size)); + modifiedDates.push(...batch.map(file => file.lastModified)); + + batch.forEach(file => formData.append(Utils.GenerateGuid(), file)); + const parameters = { method: 'POST', body: formData }; + uploads.push(...(await (await fetch(Utils.prepend(RouteStore.upload), parameters)).json())); + + runInAction(() => this.remaining += batch.length); + i = cap; } - await Promise.all(promises); + + 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) && runInAction(() => this.remaining--); + console.log(`(${this.quota - this.remaining}/${this.quota}) ${upload.name}`); + })); for (let i = 0; i < docs.length; i++) { let doc = docs[i]; @@ -136,12 +148,12 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> }; let parent = this.props.ContainingCollectionView; if (parent) { - let importContainer = Docs.Create.StackingDocument(docs, options); + let importContainer = Docs.Create.MasonryDocument(docs, options); + await GooglePhotos.Export.CollectionToAlbum({ collection: importContainer }); importContainer.singleColumn = false; Doc.AddDocToList(Doc.GetProto(parent.props.Document), "data", importContainer); !this.persistent && this.props.removeDocument && this.props.removeDocument(doc); DocumentManager.Instance.jumpToDocument(importContainer, true); - } runInAction(() => { @@ -313,7 +325,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/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index ac8497bd0..470365627 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.2); + } + + 100% { + box-shadow: 0 0 0 35px rgba(0, 0, 0, 0); + } +}
\ No newline at end of file diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 814d718be..97876204e 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -83,6 +83,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; public pullColorAnimating = false; @@ -103,6 +104,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> public startPushOutcome = action((success: boolean) => { if (!this.pushAnimating) { this.pushAnimating = true; + this.isAnimatingPulse = false; this.pushIcon = success ? "check-circle" : "stop-circle"; setTimeout(() => runInAction(() => { this.pushIcon = "arrow-alt-circle-up"; @@ -725,9 +727,12 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> return ( <div className={"linkButtonWrapper"}> <div title={`${published ? "Push" : "Publish"} to Google Docs`} className="linkButton-linker" onClick={() => { + if (!published) { + runInAction(() => this.isAnimatingPulse = true); + } DocumentDecorations.hasPushedHack = false; this.targetDoc[Pushes] = NumCast(this.targetDoc[Pushes]) + 1; - }}> + }} style={{ animation: this.isAnimatingPulse ? "shadow-pulse 1s infinite" : "none" }}> <FontAwesomeIcon className="documentdecorations-icon" icon={icon} size={published ? "sm" : "xs"} /> </div> </div> diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx index aa573f16b..147418400 100644 --- a/src/client/views/InkingControl.tsx +++ b/src/client/views/InkingControl.tsx @@ -67,8 +67,9 @@ export class InkingControl extends React.Component { cp[closest] = "rgba(" + color.rgb.r + "," + color.rgb.g + "," + color.rgb.b + "," + color.rgb.a + ")"; view.props.ContainingCollectionView.props.Document.colorPalette = new List(cp); targetDoc.backgroundColor = cp[closest]; - } else + } else { targetDoc.backgroundColor = this._selectedColor; + } if (view.props.Document.heading) { let cv = view.props.ContainingCollectionView; let ruleProvider = cv && (Cast(cv.props.Document.ruleProvider, Doc) as Doc); diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 11ec6f0c9..e35ba18e4 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -8,6 +8,26 @@ import { Doc, DocListCastAsync } from "../../new_fields/Doc"; import { List } from "../../new_fields/List"; import { DocServer } from "../DocServer"; +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"); +}; + +(Array.prototype as any).lastElement = function (this: any[]) { + if (!this.length) { + return undefined; + } + return this[this.length - 1]; +}; + + let swapDocs = async () => { let oldDoc = await Cast(CurrentUserUtils.UserDocument.linkManagerDoc, Doc); // Docs.Prototypes.MainLinkDocument().allLinks = new List<Doc>(); diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index b64986084..28edf181b 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -15,7 +15,7 @@ import { listSpec } from '../../new_fields/Schema'; import { BoolCast, Cast, FieldValue, StrCast, NumCast } from '../../new_fields/Types'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; import { RouteStore } from '../../server/RouteStore'; -import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString } from '../../Utils'; +import { emptyFunction, returnOne, returnTrue, Utils, returnEmptyString, PostToServer } from '../../Utils'; import { DocServer } from '../DocServer'; import { Docs } from '../documents/Documents'; import { ClientUtils } from '../util/ClientUtils'; @@ -40,6 +40,8 @@ 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'; @@ -128,6 +130,8 @@ export class MainView extends React.Component { window.removeEventListener("keydown", KeyManager.Instance.handle); window.addEventListener("keydown", KeyManager.Instance.handle); + this.executeGooglePhotosRoutine(); + reaction(() => { let workspaces = CurrentUserUtils.UserDocument.workspaces; let recent = CurrentUserUtils.UserDocument.recentlyClosed; @@ -145,6 +149,16 @@ export class MainView extends React.Component { }, { fireImmediately: true }); } + executeGooglePhotosRoutine = async () => { + // let imgurl = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"; + // let doc = Docs.Create.ImageDocument(imgurl, { width: 200, title: "an image of a cat" }); + // doc.caption = "Well isn't this a nice cat image!"; + // let photos = await GooglePhotos.endpoint(); + // let albumId = (await photos.albums.list(50)).albums.filter((album: any) => album.title === "This is a generically created album!")[0].id; + // console.log(await GooglePhotos.UploadImages([doc], { id: albumId })); + GooglePhotos.Query.Search({ included: [GooglePhotos.ContentCategories.ANIMALS] }).then(console.log); + } + componentWillUnMount() { window.removeEventListener("keydown", KeyManager.Instance.handle); //close presentation @@ -457,12 +471,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], ]; diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 99e5ab7b3..5fc4f36a7 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -253,7 +253,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 path = Utils.prepend(file); + let path = Utils.prepend(file.path); Docs.Get.DocumentFromType(type, path, full).then(doc => doc && this.props.addDocument(doc)); })); }); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 2591bdd8d..3377775db 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -99,7 +99,7 @@ export namespace PivotView { groups.forEach((val, key) => minSize = Math.min(minSize, val.length)); const numCols = NumCast(collection.pivotNumColumns) || Math.ceil(Math.sqrt(minSize)); - const fontSize = NumCast(collection.pivotFontSize); + const fontSize = NumCast(collection.pivotFontSize, 30); const docMap = new Map<Doc, ViewDefBounds>(); const groupNames: PivotData[] = []; @@ -114,7 +114,8 @@ export namespace PivotView { x, y: width + 50, width: width * 1.25 * numCols, - height: 100, fontSize: fontSize + height: 100, + fontSize }); for (const doc of val) { docMap.set(doc, { @@ -761,7 +762,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { return result.result === undefined ? { x: Cast(doc.x, "number"), y: Cast(doc.y, "number"), z: Cast(doc.z, "number"), width: Cast(doc.width, "number"), height: Cast(doc.height, "number") } : result.result; } - viewDefsToJSX = (views: any[]) => { + viewDefsToJSX = (views: PivotView.PivotData[]) => { let elements: ViewDefResult[] = []; if (Array.isArray(views)) { elements = views.reduce<typeof elements>((prev, ele) => { @@ -773,12 +774,12 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { return elements; } - private viewDefToJSX(viewDef: any): Opt<ViewDefResult> { + private viewDefToJSX(viewDef: PivotView.PivotData): Opt<ViewDefResult> { if (viewDef.type === "text") { const text = Cast(viewDef.text, "string"); const x = Cast(viewDef.x, "number"); const y = Cast(viewDef.y, "number"); - const z = Cast(viewDef.z, "number"); + // const z = Cast(viewDef.z, "number"); const width = Cast(viewDef.width, "number"); const height = Cast(viewDef.height, "number"); const fontSize = Cast(viewDef.fontSize, "number"); @@ -790,7 +791,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { ele: <div className="collectionFreeform-customText" style={{ transform: `translate(${x}px, ${y}px)`, width, height, fontSize - }}>{text}</div>, bounds: { x: x!, y: y!, z: z, width: width!, height: height! } + }}>{text}</div>, bounds: { x: x!, y: y!, width: width!, height: height! } }; } } diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index c2143bb82..2a8030c0c 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -41,6 +41,8 @@ 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'; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? library.add(fa.faTrash); @@ -629,12 +631,19 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu subitems.push({ description: "Open Right Alias", event: () => this.props.addDocTab && this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.dataDoc, "onRight"), icon: "caret-square-right" }); subitems.push({ description: "Open Fields", event: this.fieldsClicked, 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 existingMake = ContextMenu.Instance.findByDescription("Make..."); let makes: ContextMenuProps[] = existingMake && "subitems" in existingMake ? existingMake.subitems : []; makes.push({ description: this.props.Document.isBackground ? "Remove Background" : "Into Background", event: this.makeBackground, icon: this.props.Document.lockedPosition ? "unlock" : "lock" }); makes.push({ description: "Custom Document View", event: this.makeCustomViewClicked, icon: "concierge-bell" }); - makes.push({ description: "Metadata Field View", event: () => this.props.ContainingCollectionView && Doc.MakeTemplate(this.props.Document, StrCast(this.props.Document.title), this.props.ContainingCollectionView.props.Document), icon: "concierge-bell" }) + makes.push({ description: "Metadata Field View", event: () => this.props.ContainingCollectionView && Doc.MakeTemplate(this.props.Document, StrCast(this.props.Document.title), this.props.ContainingCollectionView.props.Document), icon: "concierge-bell" }); makes.push({ description: "Into Portal", event: this.makeIntoPortal, icon: "window-restore" }); makes.push({ description: this.layoutDoc.ignoreClick ? "Selectable" : "Unselectable", event: () => this.layoutDoc.ignoreClick = !this.layoutDoc.ignoreClick, icon: this.layoutDoc.ignoreClick ? "unlock" : "lock" }); !existingMake && cm.addItem({ description: "Make...", subitems: makes, icon: "hand-point-right" }); diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index 0ea36cdc2..8a623e648 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 * as _ from "lodash"; import { formattedTextBoxCommentPlugin, FormattedTextBoxComment } from './FormattedTextBoxComment'; import { inputRules } from 'prosemirror-inputrules'; import { select } from 'async'; @@ -44,8 +46,6 @@ import { select } from 'async'; 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; @@ -119,7 +121,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe @undoBatch public setFontColor(color: string) { - this._editorView!.state.storedMarks + this._editorView!.state.storedMarks; if (this._editorView!.state.selection.from === this._editorView!.state.selection.to) return false; if (this._editorView!.state.selection.to - this._editorView!.state.selection.from > this._editorView!.state.doc.nodeSize - 3) { this.props.Document.color = color; @@ -135,6 +137,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (this.props.isOverlay) { DragManager.StartDragFunctions.push(() => FormattedTextBox.InputBoxOverlay = undefined); } + FormattedTextBox.Instance = this; } public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -174,7 +177,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let range = tx.selection.$from.blockRange(tx.selection.$to); let text = range ? tx.doc.textBetween(range.start, range.end) : ""; let textEndSelection = tx.selection.to; - for (; textEndSelection < range!.end && text[textEndSelection - range!.start] != " "; textEndSelection++) { } + for (; textEndSelection < range!.end && text[textEndSelection - range!.start] !== " "; textEndSelection++) { } text = text.substr(0, textEndSelection - range!.start); text = text.split(" ")[text.split(" ").length - 1]; let split = text.split("::"); @@ -188,7 +191,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe this.dataDoc[key] = doc || Docs.Create.FreeformDocument([], { title: value, width: 500, height: 500 }, value); if (linkDoc) { (linkDoc as Doc).anchor2 = this.dataDoc[key] as Doc; } else DocUtils.MakeLink(this.dataDoc, this.dataDoc[key] as Doc, undefined, "Ref:" + value, undefined, undefined, id); - }) + }); }); const link = this._editorView!.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value }); const mval = this._editorView!.state.schema.marks.metadataVal.create(); @@ -392,7 +395,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) { @@ -462,18 +465,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); @@ -482,7 +484,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 }); } @@ -495,49 +503,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); } 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]; } DocumentDecorations.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; - DocumentDecorations.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; + DocumentDecorations.Instance.setPullState(unchanged); } } diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 00c069e1f..98cf7f92f 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -1,43 +1,59 @@ .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; } .imageBox-button { - padding: 0vw; - border: none; - width: 100%; - height: 100%; + padding: 0vw; + border: none; + width: 100%; + height: 100%; } .imageBox-audioBackground { @@ -49,6 +65,7 @@ border-radius: 25px; background: white; opacity: 0.3; + svg { width: 90% !important; height: 70%; @@ -59,44 +76,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 6fc94a140..b7aadcd3d 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -63,11 +63,10 @@ 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 dataDoc() { return this.props.DataDoc && (BoolCast(this.props.Document.isTemplate) || BoolCast(this.props.DataDoc.isTemplate) || this.props.DataDoc.layout === this.props.Document) ? this.props.DataDoc : Doc.GetProto(this.props.Document); } - protected createDropTarget = (ele: HTMLDivElement) => { if (this.dropDisposer) { this.dropDisposer(); @@ -372,6 +371,20 @@ 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); + } render() { // let transform = this.props.ScreenToLocalTransform().inverse(); @@ -408,6 +421,8 @@ export class ImageBox extends DocComponent<FieldViewProps, ImageDocument>(ImageD return ( <div id={id} 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 id={id} @@ -434,6 +449,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/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..6afe4ddfd --- /dev/null +++ b/src/new_fields/RichTextUtils.ts @@ -0,0 +1,335 @@ +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import { Node, Fragment, Mark } 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 } from "./Doc"; +import * as Color from "color"; +import { sinkListItem } from "prosemirror-schema-list"; +import { Utils, PostToServer } 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"; + +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> => { + let nodes: { [type: string]: Node<any>[] } = { + text: [], + image: [] + }; + let text = ToPlainText(state); + let content = state.doc.content; + content.forEach(node => node.content.forEach(node => { + const type = node.type.name; + let existing = nodes[type]; + if (existing) { + existing.push(node); + } else { + nodes[type] = [node]; + } + })); + let linkRequests = ExtractLinks(nodes.text); + let imageRequests = await ExtractImages(nodes.image); + return { + text, + requests: [...linkRequests, ...imageRequests] + }; + }; + + type BulletPosition = { value: number, sinks: number }; + + interface MediaItem { + baseUrl: string; + filename: string; + width: number; + } + export const Import = async (documentId: GoogleApiClientUtils.Docs.DocumentId): Promise<Opt<GoogleApiClientUtils.Docs.ImportResult>> => { + const document = await GoogleApiClientUtils.Docs.retrieve({ documentId }); + if (!document) { + return undefined; + } + + const title = document.title!; + const { text, paragraphs } = GoogleApiClientUtils.Docs.Utils.extractText(document); + let state = FormattedTextBox.blankState(); + let structured = parseLists(paragraphs); + const inline = document.inlineObjects; + let inlineUrls: MediaItem[] = []; + if (inline) { + inlineUrls = Object.keys(inline).map(key => { + const embedded = inline[key].inlineObjectProperties!.embeddedObject!; + const baseUrl = embedded.imageProperties!.contentUri!; + const filename = `upload_${Utils.GenerateGuid()}.png`; + const width = embedded.size!.width!.magnitude!; + return { baseUrl, filename, width }; + }); + } + + let position = 3; + let lists: ListGroup[] = []; + const indentMap = new Map<ListGroup, BulletPosition[]>(); + let globalOffset = 0; + const nodes = structured.map(element => { + if (Array.isArray(element)) { + lists.push(element); + let positions: BulletPosition[] = []; + let items = element.map(paragraph => { + let item = listItem(state.schema, paragraph.runs); + let sinks = paragraph.bullet!; + positions.push({ + value: position + globalOffset, + sinks + }); + position += item.nodeSize; + globalOffset += 2 * sinks; + return item; + }); + indentMap.set(element, positions); + return list(state.schema, items); + } else { + let paragraph = paragraphNode(state.schema, element.runs); + position += paragraph.nodeSize; + return paragraph; + } + }); + 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); + } + } + } + + const uploads = await PostToServer(RouteStore.googlePhotosMediaDownload, { mediaItems: inlineUrls }); + for (let i = 0; i < uploads.length; i++) { + const src = Utils.prepend(`/files/${uploads[i].fileNames.clean}`); + state = state.apply(state.tr.insert(0, schema.nodes.image.create({ src, width: inlineUrls[i].width }))); + } + + 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 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 MarkMapping = new Map<keyof docs_v1.Schema$TextStyle, string>([ + ["bold", "strong"], + ["italic", "em"], + ["foregroundColor", "pFontColor"] + ]); + + 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 = MarkMapping.get(targeted) || targeted; + + value.url && (attributes.href = value.url); + if (value.color) { + let object: { [key: string]: number } = value.color.rgbColor; + attributes.color = Color.rgb(Object.values(object).map(value => value * 255)).hex(); + } + + let mark = schema.mark(schema.marks[converted], attributes); + mark && marks.push(mark); + } + }); + return marks; + }; + + interface LinkInformation { + startIndex: number; + endIndex: number; + bold: boolean; + url: string; + } + + const ExtractLinks = (nodes: Node<any>[]) => { + let links: docs_v1.Schema$Request[] = []; + let position = 1; + for (let node of nodes) { + let link, length = node.nodeSize; + let marks = node.marks; + if (marks.length && (link = marks.find(mark => mark.type.name === "link"))) { + links.push(Encode({ + startIndex: position, + endIndex: position + length, + url: link.attrs.href, + bold: false + })); + } + position += length; + } + return links; + }; + + const ExtractImages = async (nodes: Node<any>[]) => { + const images: docs_v1.Schema$Request[] = []; + let position = 1; + for (let node of nodes) { + const length = node.nodeSize; + const attrs = node.attrs; + const uri = attrs.src; + const baseUrls = await GooglePhotos.Transactions.UploadThenFetch([Docs.Create.ImageDocument(uri)]); + if (!baseUrls) { + continue; + } + images.push({ + insertInlineImage: { + uri: baseUrls[0], + objectSize: { width: { magnitude: parseFloat(attrs.width.replace("px", "")), unit: "PT" } }, + location: { index: position + length } + } + }); + position += length; + } + return images; + }; + + const Encode = (information: LinkInformation) => { + return { + updateTextStyle: { + fields: "*", + range: { + startIndex: information.startIndex, + endIndex: information.endIndex + }, + textStyle: { + bold: true, + link: { url: information.url }, + foregroundColor: { color: { rgbColor: { red: 0.0, green: 0.0, blue: 1.0 } } } + } + } + }; + }; + } + +}
\ No newline at end of file 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/CustomizedWrapper/filters.js b/src/server/apis/google/CustomizedWrapper/filters.js new file mode 100644 index 000000000..576a90b75 --- /dev/null +++ b/src/server/apis/google/CustomizedWrapper/filters.js @@ -0,0 +1,46 @@ +'use strict'; + +const DateFilter = require('../common/date_filter'); +const MediaTypeFilter = require('./media_type_filter'); +const ContentFilter = require('./content_filter'); + +class Filters { + constructor(includeArchivedMedia = false) { + this.includeArchivedMedia = includeArchivedMedia; + } + + setDateFilter(dateFilter) { + this.dateFilter = dateFilter; + return this; + } + + setContentFilter(contentFilter) { + this.contentFilter = contentFilter; + return this; + } + + setMediaTypeFilter(mediaTypeFilter) { + this.mediaTypeFilter = mediaTypeFilter; + return this; + } + + setIncludeArchivedMedia(includeArchivedMedia) { + this.includeArchivedMedia = includeArchivedMedia; + return this; + } + + toJSON() { + return { + dateFilter: this.dateFilter instanceof DateFilter ? this.dateFilter.toJSON() : this.dateFilter, + mediaTypeFilter: this.mediaTypeFilter instanceof MediaTypeFilter ? + this.mediaTypeFilter.toJSON() : + this.mediaTypeFilter, + contentFilter: this.contentFilter instanceof ContentFilter ? + this.contentFilter.toJSON() : + this.contentFilter, + includeArchivedMedia: this.includeArchivedMedia + }; + } +} + +module.exports = Filters;
\ No newline at end of file diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts index 8785cd974..e0bd8a800 100644 --- a/src/server/apis/google/GoogleApiServerUtils.ts +++ b/src/server/apis/google/GoogleApiServerUtils.ts @@ -1,10 +1,13 @@ -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'); /** * 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,66 +35,121 @@ export namespace GoogleApiServerUtils { Slides = "Slides" } - export interface CredentialPaths { - credentials: string; - token: string; + credentialsPath: string; + tokenPath: 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: CredentialPaths) => { + 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 RetrieveCredentials = (paths: CredentialPaths) => { + return new Promise<TokenResult>((resolve, reject) => { + readFile(paths.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), paths.tokenPath).then(resolve, reject); }); }); }; + export const RetrieveAccessToken = (paths: CredentialPaths) => { + return new Promise<string>((resolve, reject) => { + RetrieveCredentials(paths).then( + credentials => resolve(credentials.token.access_token!), + error => reject(`Error: unable to authenticate Google Photos API request.\n${error}`) + ); + }); + }; + export const RetrievePhotosEndpoint = (paths: CredentialPaths) => { + 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, token_path: 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) => { + return new Promise<TokenResult>((resolve, reject) => { readFile(token_path, (err, token) => { // Check if we have previously stored a token. if (err) { return getNewToken(oAuth2Client, token_path).then(resolve, reject); } - oAuth2Client.setCredentials(parseBuffer(token)); - resolve(oAuth2Client); + let parsed: Credentials = parseBuffer(token); + if (parsed.expiry_date! < new Date().getTime()) { + return refreshToken(parsed, client_id, client_secret, oAuth2Client, token_path).then(resolve, reject); + } + oAuth2Client.setCredentials(parsed); + resolve({ token: parsed, client: oAuth2Client }); }); }); } + const refreshEndpoint = "https://oauth2.googleapis.com/token"; + const refreshToken = (credentials: Credentials, client_id: string, client_secret: string, oAuth2Client: OAuth2Client, token_path: string) => { + return new Promise<TokenResult>((resolve, reject) => { + 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(response => { + let parsed = JSON.parse(response); + credentials.access_token = parsed.access_token; + credentials.expiry_date = new Date().getTime() + (parsed.expires_in * 1000); + writeFile(token_path, JSON.stringify(credentials), (err) => { + if (err) { + console.error(err); + reject(err); + } + console.log('Refreshed token stored to', token_path); + 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. @@ -96,7 +157,7 @@ export namespace GoogleApiServerUtils { * @param {getEventsCallback} callback The callback for the authorized client. */ function getNewToken(oAuth2Client: OAuth2Client, token_path: string) { - return new Promise<OAuth2Client>((resolve, reject) => { + return new Promise<TokenResult>((resolve, reject) => { const authUrl = oAuth2Client.generateAuthUrl({ access_type: 'offline', scope: SCOPES.map(relative => prefix + relative), @@ -122,7 +183,7 @@ export namespace GoogleApiServerUtils { } console.log('Token stored to', token_path); }); - resolve(oAuth2Client); + resolve({ token, client: oAuth2Client }); }); }); }); diff --git a/src/server/apis/google/GooglePhotosUploadUtils.ts b/src/server/apis/google/GooglePhotosUploadUtils.ts index 35f986250..c2656cc1c 100644 --- a/src/server/apis/google/GooglePhotosUploadUtils.ts +++ b/src/server/apis/google/GooglePhotosUploadUtils.ts @@ -5,6 +5,7 @@ import { Utils } from '../../../Utils'; import * as path from 'path'; import { Opt } from '../../../new_fields/Doc'; import * as sharp from 'sharp'; +import { MediaItemCreationResult, NewMediaItemResult } from './SharedTypes'; const uploadDirectory = path.join(__dirname, "../../public/files/"); @@ -49,27 +50,44 @@ export namespace GooglePhotosUploadUtils { uri: prepend('uploads'), body }; - return new Promise<any>(resolve => request(parameters, (error, _response, body) => resolve(error ? undefined : body))); + return new Promise<any>((resolve, reject) => request(parameters, (error, _response, body) => { + if (error) { + return reject(error); + } + resolve(body); + })); }; - export const CreateMediaItems = (newMediaItems: any[], album?: { id: string }) => { - return new Promise<any>((resolve, reject) => { + + + export const CreateMediaItems = async (newMediaItems: any[], album?: { id: string }): Promise<MediaItemCreationResult> => { + const quota = newMediaItems.length; + let handled = 0; + const newMediaItemResults: NewMediaItemResult[] = []; + while (handled < quota) { + const cap = Math.min(newMediaItems.length, handled + 50); + const batch = newMediaItems.slice(handled, cap); + console.log(batch.length); const parameters = { method: 'POST', headers: headers('json'), uri: prepend('mediaItems:batchCreate'), - body: { newMediaItems } as any, + body: { newMediaItems: batch } as any, json: true }; album && (parameters.body.albumId = album.id); - request(parameters, (error, _response, body) => { - if (error) { - reject(error); - } else { - resolve(body); - } - }); - }); + newMediaItemResults.push(...(await new Promise<MediaItemCreationResult>((resolve, reject) => { + request(parameters, (error, _response, body) => { + if (error) { + reject(error); + } else { + resolve(body); + } + }); + })).newMediaItemResults); + handled = cap; + } + return { newMediaItemResults }; }; } @@ -87,10 +105,12 @@ export namespace DownloadUtils { LARGE: { width: 900, suffix: "_l" }, }; - const png = ".png"; - const pngs = [".png", ".PNG"]; - const jpgs = [".jpg", ".JPG", ".jpeg", ".JPEG"]; - const formats = [".jpg", ".png", ".gif"]; + 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"; @@ -106,7 +126,7 @@ export namespace DownloadUtils { 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; + const extension = (path.extname(url) || path.extname(resolved)).toLowerCase() || ".png"; let information: UploadInformation = { mediaPaths: [], fileNames: { clean: resolved } @@ -137,19 +157,26 @@ export namespace DownloadUtils { 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(); + } else if (![...imageFormats, ...videoFormats].includes(extension.toLowerCase())) { + return resolve(undefined); + } + 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(url).pipe(resizer.resizer).pipe(fs.createWriteStream(mediaPath)) + .on('close', resolve) + .on('error', reject); + }); + } } - for (let resizer of resizers) { - const suffix = resizer.suffix; - let mediaPath: string; + if (!isLocal) { 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); + stream(url).pipe(fs.createWriteStream(uploadDirectory + resolved)).on('close', resolve); }); } resolve(information); 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/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 index 07c02d56c..fec1625f5 100644 --- a/src/server/credentials/google_docs_token.json +++ b/src/server/credentials/google_docs_token.json @@ -1 +1 @@ -{"access_token":"ya29.GltjB4-x03xFpd2NY2555cxg1xlT_ajqRi78M9osOfdOF2jTIjlPkn_UZL8cUwVP0DPC8rH3vhhg8RpspFe8Vewx92shAO3RPos_uMH0CUqEiCiZlaaB5I3Jq3Mv","refresh_token":"1/teUKUqGKMLjVqs-eed0L8omI02pzSxMUYaxGc2QxBw0","scope":"https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents.readonly","token_type":"Bearer","expiry_date":1565654175862}
\ No newline at end of file +{"access_token":"ya29.GlyBB1MlCG7GL2pYFleLp9uUJoN6s0_PFBDLUIhyrKAY4kkVo7vbuaW_zmkJs1Fym0f7NVpaYvFsBK2dbN6Qn5P8bWNW2NsHNNGcwbyGIS8H52GUlyCsawNt6PTnOw","refresh_token":"1/HTv_xFHszu2Nf3iiFrUTaeKzC_Vp2-6bpIB06xW_WHI","scope":"https://www.googleapis.com/auth/presentations.readonly https://www.googleapis.com/auth/documents.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/documents https://www.googleapis.com/auth/photoslibrary https://www.googleapis.com/auth/photoslibrary.appendonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/presentations https://www.googleapis.com/auth/photoslibrary.sharing","token_type":"Bearer","expiry_date":1568274162450}
\ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index 50ce2b14e..101a4f63f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -38,14 +38,12 @@ import flash = require('connect-flash'); import { Search } from './Search'; import _ = require('lodash'); import * as Archiver from 'archiver'; +import * as request_promise from 'request-promise'; var AdmZip = require('adm-zip'); import * as YoutubeApi from "./apis/youtube/youtubeApiSample"; import { Response } from 'express-serve-static-core'; import { GoogleApiServerUtils } from "./apis/google/GoogleApiServerUtils"; -import { GaxiosResponse } from 'gaxios'; -import { Opt } from '../new_fields/Doc'; -import { docs_v1 } from 'googleapis'; -import { Endpoint } from 'googleapis-common'; +import { GooglePhotosUploadUtils, DownloadUtils as UploadUtils } from './apis/google/GooglePhotosUploadUtils'; const MongoStore = require('connect-mongo')(session); const mongoose = require('mongoose'); const probe = require("probe-image-size"); @@ -116,7 +114,7 @@ function addSecureRoute(method: Method, ) { let abstracted = (req: express.Request, res: express.Response) => { if (req.user) { - handler(req.user as any, res, req); + handler(req.user, res, req); } else { req.session!.target = req.originalUrl; onRejection(res, req); @@ -157,6 +155,12 @@ app.get("/buxton", (req, res) => { command_line('python scraper.py', cwd).then(onResolved, tryPython3); }); +const STATUS = { + OK: 200, + BAD_REQUEST: 400, + EXECUTION_ERROR: 500 +}; + const command_line = (command: string, fromDirectory?: string) => { return new Promise<string>((resolve, reject) => { let options: ExecOptions = {}; @@ -194,7 +198,7 @@ app.get("/version", (req, res) => { // SEARCH const solrURL = "http://localhost:8983/solr/#/dash"; -// GETTERS +// GETTERSå app.get("/search", async (req, res) => { const solrQuery: any = {}; @@ -420,10 +424,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); @@ -434,7 +438,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); } }); }); @@ -556,50 +560,31 @@ 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)); - }); - } - names.push(`/files/` + 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); + await UploadUtils.UploadImage(uploadDirectory + filename, filename).catch(() => console.log(`Unable to process ${filename}`)); + results.push({ name, type, path: `/files/${filename}` }); + console.log(path.basename(name)); } - res.send(names); + console.log("All files traversed!"); + _success(res, results); }); } ); @@ -613,7 +598,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" }, @@ -634,7 +619,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); @@ -801,19 +786,19 @@ 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 tokenPath = path.join(__dirname, "./credentials/google_docs_token.json"); const EndpointHandlerMap = new Map<GoogleApiServerUtils.Action, GoogleApiServerUtils.ApiRouter>([ ["create", (api, params) => api.create(params)], - ["retrieve", (api, params) => api.get(params)], + ["retrieve", (api, params) => api.get(params, { params: "fields=inlineObjects" })], ["update", (api, params) => api.batchUpdate(params)], ]); 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, tokenPath }).then(endpoint => { let handler = EndpointHandlerMap.get(action); if (endpoint && handler) { let execute = handler(endpoint, req.body).then( @@ -827,6 +812,66 @@ app.post(RouteStore.googleDocs + "/:sector/:action", (req, res) => { }); }); +app.get(RouteStore.googlePhotosAccessToken, (req, res) => GoogleApiServerUtils.RetrieveAccessToken({ credentialsPath, tokenPath }).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!"; + +app.post(RouteStore.googlePhotosMediaUpload, async (req, res) => { + const media: GooglePhotosUploadUtils.MediaInput[] = req.body.media; + await GooglePhotosUploadUtils.initialize({ uploadDirectory, credentialsPath, tokenPath }); + const newMediaItems = await Promise.all(media.map(async element => { + const uploadToken = await GooglePhotosUploadUtils.DispatchGooglePhotosUpload(element.url).catch(error => _error(res, tokenError, error)); + return !uploadToken ? undefined : { + description: element.description, + simpleMediaItem: { uploadToken } + }; + })); + if (!newMediaItems.every(item => item)) { + 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."; + +app.post(RouteStore.googlePhotosMediaDownload, async (req, res) => { + const contents: { mediaItems: MediaItem[] } = req.body; + if (contents) { + const pending = contents.mediaItems.map(item => + UploadUtils.UploadImage(item.baseUrl, item.filename, prefix) + ); + const completed = await Promise.all(pending).catch(error => _error(res, downloadError, error)); + Array.isArray(completed) && _success(res, completed); + return; + } + _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 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 |