diff options
author | bobzel <zzzman@gmail.com> | 2024-05-14 23:15:24 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2024-05-14 23:15:24 -0400 |
commit | 3534aaf88a3c30a474b3b5a5b7f04adfe6f15fac (patch) | |
tree | 47fb7a8671b209bd4d76e0f755a5b035c6936607 /src/client/documents/DocUtils.ts | |
parent | 87bca251d87b5a95da06b2212400ce9427152193 (diff) | |
parent | 5cb7ad90e120123ca572e8ef5b1aa6ca41581134 (diff) |
Merge branch 'restoringEslint' into sarah-ai-visualization
Diffstat (limited to 'src/client/documents/DocUtils.ts')
-rw-r--r-- | src/client/documents/DocUtils.ts | 875 |
1 files changed, 875 insertions, 0 deletions
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts new file mode 100644 index 000000000..0c9fe0315 --- /dev/null +++ b/src/client/documents/DocUtils.ts @@ -0,0 +1,875 @@ +/* eslint-disable prefer-destructuring */ +/* eslint-disable default-param-last */ +/* eslint-disable no-use-before-define */ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { saveAs } from 'file-saver'; +import * as JSZip from 'jszip'; +import { action, runInAction } from 'mobx'; +import { ClientUtils } from '../../ClientUtils'; +import * as JSZipUtils from '../../JSZipUtils'; +import { decycle } from '../../decycler/decycler'; +import { DateField } from '../../fields/DateField'; +import { Doc, DocListCast, Field, FieldResult, FieldType, LinkedTo, Opt, StrListCast } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { Id } from '../../fields/FieldSymbols'; +import { InkDataFieldName, InkField } from '../../fields/InkField'; +import { List, ListFieldName } from '../../fields/List'; +import { ProxyField } from '../../fields/Proxy'; +import { RichTextField } from '../../fields/RichTextField'; +import { ComputedField, ScriptField } from '../../fields/ScriptField'; +import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } from '../../fields/Types'; +import { AudioField, CsvField, ImageField, PdfField, VideoField, WebField } from '../../fields/URLField'; +import { SharingPermissions } from '../../fields/util'; +import { Upload } from '../../server/SharedMediaTypes'; +import { DocServer } from '../DocServer'; +import { Networking } from '../Network'; +import { LinkManager } from '../util/LinkManager'; +import { ScriptingGlobals } from '../util/ScriptingGlobals'; +import { SerializationHelper } from '../util/SerializationHelper'; +import { UndoManager, undoable } from '../util/UndoManager'; +import { ContextMenu } from '../views/ContextMenu'; +import { ContextMenuProps } from '../views/ContextMenuItem'; +import { LinkDescriptionPopup } from '../views/nodes/LinkDescriptionPopup'; +import { OpenWhere } from '../views/nodes/OpenWhere'; +import { TaskCompletionBox } from '../views/nodes/TaskCompletedBox'; +import { DocumentType } from './DocumentTypes'; +import { Docs, DocumentOptions } from './Documents'; + +const { DFLT_IMAGE_NATIVE_DIM } = require('../views/global/globalCssVariables.module.scss'); // prettier-ignore + +const defaultNativeImageDim = Number(DFLT_IMAGE_NATIVE_DIM.replace('px', '')); + +export namespace DocUtils { + function matchFieldValue(doc: Doc, key: string, valueIn: any): boolean { + let value = valueIn; + const hasFunctionFilter = ClientUtils.HasFunctionFilter(value); + if (hasFunctionFilter) { + return hasFunctionFilter(StrCast(doc[key])); + } + if (key === LinkedTo) { + // links are not a field value, so handled here. value is an expression of form ([field=]idToDoc("...")) + const allLinks = Doc.Links(doc); + const matchLink = (val: string, anchor: Doc) => { + const linkedToExp = (val ?? '').split('='); + if (linkedToExp.length === 1) return Field.toScriptString(anchor) === val; + return Field.toScriptString(DocCast(anchor[linkedToExp[0]])) === linkedToExp[1]; + }; + // prettier-ignore + return (value === Doc.FilterNone && !allLinks.length) || + (value === Doc.FilterAny && !!allLinks.length) || + (allLinks.some(link => matchLink(value,DocCast(link.link_anchor_1)) || + matchLink(value,DocCast(link.link_anchor_2)) )); + } + if (typeof value === 'string') { + value = value.replace(`,${ClientUtils.noRecursionHack}`, ''); + } + const fieldVal = doc[key]; + // prettier-ignore + if ((value === Doc.FilterAny && fieldVal !== undefined) || + (value === Doc.FilterNone && fieldVal === undefined)) { + return true; + } + const vals = StrListCast(fieldVal); // list typing is very imperfect. casting to a string list doesn't mean that the entries will actually be strings + if (vals.length) { + return vals.some(v => typeof v === 'string' && v.includes(value)); // bcz: arghh: Todo: comparison should be parameterized as exact, or substring + } + return Field.toString(fieldVal as FieldType).includes(value); // bcz: arghh: Todo: comparison should be parameterized as exact, or substring + } + /** + * @param docs + * @param childFilters + * @param childFiltersByRanges + * @param parentCollection + * Given a list of docs and childFilters, @returns the list of Docs that match those filters + */ + export function FilterDocs(childDocs: Doc[], childFilters: string[], childFiltersByRanges: string[], parentCollection?: Doc) { + if (!childFilters?.length && !childFiltersByRanges?.length) { + return childDocs.filter(d => !d.cookies); // remove documents that need a cookie if there are no filters to provide one + } + + const filterFacets: { [key: string]: { [value: string]: string } } = {}; // maps each filter key to an object with value=>modifier fields + childFilters.forEach(filter => { + const fields = filter.split(Doc.FilterSep); + const key = fields[0]; + const value = fields[1]; + const modifiers = fields[2]; + if (!filterFacets[key]) { + filterFacets[key] = {}; + } + filterFacets[key][value] = modifiers; + }); + + const filteredDocs = childFilters.length + ? childDocs.filter(d => { + if (d.z) return true; + // if the document needs a cookie but no filter provides the cookie, then the document does not pass the filter + if (d.cookies && (!filterFacets.cookies || !Object.keys(filterFacets.cookies).some(key => d.cookies === key))) { + return false; + } + const facetKeys = Object.keys(filterFacets).filter(fkey => fkey !== 'cookies' && fkey !== ClientUtils.noDragDocsFilter.split(Doc.FilterSep)[0]); + // eslint-disable-next-line no-restricted-syntax + for (const facetKey of facetKeys) { + const facet = filterFacets[facetKey]; + + // facets that match some value in the field of the document (e.g. some text field) + const matches = Object.keys(facet).filter(value => value !== 'cookies' && facet[value] === 'match'); + + // facets that have a check next to them + const checks = Object.keys(facet).filter(value => facet[value] === 'check'); + + // metadata facets that exist + const exists = Object.keys(facet).filter(value => facet[value] === 'exists'); + + // facets that unset metadata (a hack for making cookies work) + const unsets = Object.keys(facet).filter(value => facet[value] === 'unset'); + + // facets that specify that a field must not match a specific value + const xs = Object.keys(facet).filter(value => facet[value] === 'x'); + + if (!unsets.length && !exists.length && !xs.length && !checks.length && !matches.length) return true; + const failsNotEqualFacets = !xs.length ? false : xs.some(value => matchFieldValue(d, facetKey, value)); + const satisfiesCheckFacets = !checks.length ? true : checks.some(value => matchFieldValue(d, facetKey, value)); + const satisfiesExistsFacets = !exists.length ? true : facetKey !== LinkedTo ? d[facetKey] !== undefined : Doc.Links(d).length; + const satisfiesUnsetsFacets = !unsets.length ? true : d[facetKey] === undefined; + const satisfiesMatchFacets = !matches.length + ? true + : matches.some(value => { + if (facetKey.startsWith('*')) { + // fields starting with a '*' are used to match families of related fields. ie, *modificationDate will match text_modificationDate, data_modificationDate, etc + const allKeys = Array.from(Object.keys(d)); + allKeys.push(...Object.keys(Doc.GetProto(d))); + const keys = allKeys.filter(key => key.includes(facetKey.substring(1))); + return keys.some(key => Field.toString(d[key] as FieldType).includes(value)); + } + return Field.toString(d[facetKey] as FieldType).includes(value); + }); + // if we're ORing them together, the default return is false, and we return true for a doc if it satisfies any one set of criteria + if (parentCollection?.childFilters_boolean === 'OR') { + if (satisfiesUnsetsFacets && satisfiesExistsFacets && satisfiesCheckFacets && !failsNotEqualFacets && satisfiesMatchFacets) return true; + } + // if we're ANDing them together, the default return is true, and we return false for a doc if it doesn't satisfy any set of criteria + else if (!satisfiesUnsetsFacets || !satisfiesExistsFacets || !satisfiesCheckFacets || failsNotEqualFacets || (matches.length && !satisfiesMatchFacets)) return false; + } + return parentCollection?.childFilters_boolean !== 'OR'; + }) + : childDocs; + const rangeFilteredDocs = filteredDocs.filter(d => { + for (let i = 0; i < childFiltersByRanges.length; i += 3) { + const key = childFiltersByRanges[i]; + const min = Number(childFiltersByRanges[i + 1]); + const max = Number(childFiltersByRanges[i + 2]); + const val = typeof d[key] === 'string' ? (Number(StrCast(d[key])).toString() === StrCast(d[key]) ? Number(StrCast(d[key])) : undefined) : Cast(d[key], 'number', null); + if (val === undefined) { + // console.log("Should 'undefined' pass range filter or not?") + } else if (val < min || val > max) return false; + } + return true; + }); + return rangeFilteredDocs; + } + + export function MakeLink(source: Doc, target: Doc, linkSettings: { link_relationship?: string; link_description?: string }, id?: string, showPopup?: number[]) { + if (!linkSettings.link_relationship) linkSettings.link_relationship = target.type === DocumentType.RTF ? 'Commentary:Comments On' : 'link'; + if (target.doc === Doc.UserDoc()) return undefined; + + const makeLink = action((linkDoc: Doc, showAt?: number[]) => { + if (showAt) { + LinkManager.Instance.currentLink = linkDoc; + + TaskCompletionBox.textDisplayed = 'Link Created'; + TaskCompletionBox.popupX = showAt[0]; + TaskCompletionBox.popupY = showAt[1] - 33; + TaskCompletionBox.taskCompleted = true; + + LinkDescriptionPopup.Instance.popupX = showAt[0]; + LinkDescriptionPopup.Instance.popupY = showAt[1]; + LinkDescriptionPopup.Instance.display = true; + + const rect = document.body.getBoundingClientRect(); + if (LinkDescriptionPopup.Instance.popupX + 200 > rect.width) { + LinkDescriptionPopup.Instance.popupX -= 190; + TaskCompletionBox.popupX -= 40; + } + if (LinkDescriptionPopup.Instance.popupY + 100 > rect.height) { + LinkDescriptionPopup.Instance.popupY -= 40; + TaskCompletionBox.popupY -= 40; + } + + setTimeout( + action(() => { + TaskCompletionBox.taskCompleted = false; + }), + 2500 + ); + } + return linkDoc; + }); + + const a = source.layout_unrendered ? 'link_anchor_1?.annotationOn' : 'link_anchor_1'; + const b = target.layout_unrendered ? 'link_anchor_2?.annotationOn' : 'link_anchor_2'; + + return makeLink( + Docs.Create.LinkDocument( + source, + target, + { + acl_Guest: SharingPermissions.Augment, + _acl_Guest: SharingPermissions.Augment, + title: ComputedField.MakeFunction('generateLinkTitle(this)') as any, + link_anchor_1_useSmallAnchor: source.useSmallAnchor ? true : undefined, + link_anchor_2_useSmallAnchor: target.useSmallAnchor ? true : undefined, + link_relationship: linkSettings.link_relationship, + link_description: linkSettings.link_description, + x: ComputedField.MakeFunction(`((this.${a}?.x||0)+(this.${b}?.x||0))/2`) as any, + y: ComputedField.MakeFunction(`((this.${a}?.y||0)+(this.${b}?.y||0))/2`) as any, + link_autoMoveAnchors: true, + _lockedPosition: true, + _layout_showCaption: '', // removed since they conflict with showing a link with a LinkBox (ie, line, not comparison box) + _layout_showTitle: '', + // _layout_showCaption: 'link_description', + // _layout_showTitle: 'link_relationship', + }, + id + ), + showPopup + ); + } + + export function AssignScripts(doc: Doc, scripts?: { [key: string]: string | undefined }, funcs?: { [key: string]: string }) { + scripts && + Object.keys(scripts).forEach(key => { + const script = scripts[key]; + if (ScriptCast(doc[key])?.script.originalScript !== scripts[key] && script) { + (key.startsWith('_') ? doc : Doc.GetProto(doc))[key] = ScriptField.MakeScript(script, { + this: Doc.name, + dragData: Doc.DocDragDataName, + value: 'any', + _readOnly_: 'boolean', + scriptContext: 'any', + documentView: Doc.name, + heading: Doc.name, + checked: 'boolean', + containingTreeView: Doc.name, + altKey: 'boolean', + ctrlKey: 'boolean', + shiftKey: 'boolean', + }); + } + }); + funcs && + Object.keys(funcs) + .filter(key => !key.endsWith('-setter')) + .forEach(key => { + const cfield = ComputedField.WithoutComputed(() => FieldValue(doc[key])); + if (ScriptCast(cfield)?.script.originalScript !== funcs[key]) { + const setFunc = Cast(funcs[key + '-setter'], 'string', null); + (key.startsWith('_') ? doc : Doc.GetProto(doc))[key] = funcs[key] ? ComputedField.MakeFunction(funcs[key], { dragData: Doc.DocDragDataName }, { _readOnly_: true }, setFunc) : undefined; + } + }); + return doc; + } + export function AssignOpts(doc: Doc | undefined, reqdOpts: DocumentOptions, items?: Doc[]) { + if (doc) { + const compareValues = (val1: any, val2: any) => { + if (val1 instanceof List && val2 instanceof List && val1.length === val2.length) { + return !val1.some(v => !val2.includes(v)) || !val2.some(v => val1.includes(v)); + } + return val1 === val2; + }; + Object.entries(reqdOpts).forEach(pair => { + const targetDoc = pair[0].startsWith('_') ? doc : Doc.GetProto(doc as Doc); + if (!Object.getOwnPropertyNames(targetDoc).includes(pair[0].replace(/^_/, '')) || !compareValues(pair[1], targetDoc[pair[0]])) { + targetDoc[pair[0]] = pair[1]; + } + }); + items?.forEach(item => !DocListCast(doc.data).includes(item) && Doc.AddDocToList(Doc.GetProto(doc), 'data', item)); + items && DocListCast(doc.data).forEach(item => Doc.IsSystem(item) && !items.includes(item) && Doc.RemoveDocFromList(Doc.GetProto(doc), 'data', item)); + } + return doc; + } + export function AssignDocField(doc: Doc, field: string, creator: (reqdOpts: DocumentOptions, items?: Doc[]) => Doc, reqdOpts: DocumentOptions, items?: Doc[], scripts?: { [key: string]: string }, funcs?: { [key: string]: string }) { + // eslint-disable-next-line no-return-assign + return DocUtils.AssignScripts(DocUtils.AssignOpts(DocCast(doc[field]), reqdOpts, items) ?? (doc[field] = creator(reqdOpts, items)), scripts, funcs); + } + + /** + * + * @param type the type of file. + * @param path the path to the file. + * @param options the document options. + * @param overwriteDoc the placeholder loading doc. + * @returns + */ + export async function DocumentFromType(type: string, path: string, options: DocumentOptions, overwriteDoc?: Doc): Promise<Opt<Doc>> { + let ctor: ((path: string, options: DocumentOptions, overwriteDoc?: Doc) => Doc | Promise<Doc | undefined>) | undefined; + if (type.indexOf('image') !== -1) { + ctor = Docs.Create.ImageDocument; + if (!options._width) options._width = 300; + } + if (type.indexOf('video') !== -1) { + ctor = Docs.Create.VideoDocument; + if (!options._width) options._width = 600; + if (!options._height) options._height = ((options._width as number) * 2) / 3; + } + if (type.indexOf('audio') !== -1) { + ctor = Docs.Create.AudioDocument; + } + if (type.indexOf('pdf') !== -1) { + ctor = Docs.Create.PdfDocument; + if (!options._width) options._width = 400; + if (!options._height) options._height = ((options._width as number) * 1200) / 927; + } + if (type.indexOf('csv') !== -1) { + ctor = Docs.Create.DataVizDocument; + if (!options._width) options._width = 400; + if (!options._height) options._height = ((options._width as number) * 1200) / 927; + } + // TODO:al+glr + // if (type.indexOf("map") !== -1) { + // ctor = Docs.Create.MapDocument; + // if (!options._width) options._width = 800; + // if (!options._height) options._height = (options._width as number) * 3 / 4; + // } + if (type.indexOf('html') !== -1) { + if (path.includes(window.location.hostname)) { + const s = path.split('/'); + const id = s[s.length - 1]; + return DocServer.GetRefField(id).then(field => { + if (field instanceof Doc) { + const embedding = Doc.MakeEmbedding(field); + embedding.x = (options.x as number) || 0; + embedding.y = (options.y as number) || 0; + embedding._width = (options._width as number) || 300; + embedding._height = (options._height as number) || (options._width as number) || 300; + return embedding; + } + return undefined; + }); + } + ctor = Docs.Create.WebDocument; + // eslint-disable-next-line no-param-reassign + options = { ...options, _width: 400, _height: 512, title: path }; + } + + return ctor ? ctor(path, overwriteDoc ? { ...options, title: StrCast(overwriteDoc.title, path) } : options, overwriteDoc) : undefined; + } + + export function addDocumentCreatorMenuItems(docTextAdder: (d: Doc) => void, docAdder: (d: Doc) => void, x: number, y: number, simpleMenu: boolean = false, pivotField?: string, pivotValue?: string): void { + const documentList: ContextMenuProps[] = DocListCast(DocListCast(Doc.MyTools?.data)[0]?.data) + .filter(btnDoc => !btnDoc.hidden) + .map(btnDoc => Cast(btnDoc?.dragFactory, Doc, null)) + .filter(doc => doc && doc !== Doc.UserDoc().emptyTrail && doc.title) + .map(dragDoc => ({ + description: ':' + StrCast(dragDoc.title).replace('Untitled ', ''), + event: undoable(() => { + const newDoc = DocUtils.copyDragFactory(dragDoc); + if (newDoc) { + newDoc.author = ClientUtils.CurrentUserEmail(); + newDoc.x = x; + newDoc.y = y; + Doc.SetSelectOnLoad(newDoc); + if (pivotField) { + newDoc[pivotField] = pivotValue; + } + docAdder?.(newDoc); + } + }, StrCast(dragDoc.title)), + icon: Doc.toIcon(dragDoc), + })) as ContextMenuProps[]; + ContextMenu.Instance.addItem({ + description: 'Create document', + subitems: documentList, + icon: 'file', + }); + !simpleMenu && + ContextMenu.Instance.addItem({ + description: 'Styled Notes', + subitems: DocListCast((Doc.UserDoc().template_notes as Doc).data).map(note => ({ + description: ':' + StrCast(note.title), + event: undoable(() => { + const textDoc = Docs.Create.TextDocument('', { + _width: 200, + x, + y, + _layout_autoHeight: note._layout_autoHeight !== false, + title: StrCast(note.title) + '#' + (note.embeddingCount = NumCast(note.embeddingCount) + 1), + }); + textDoc.layout_fieldKey = 'layout_' + note.title; + textDoc[textDoc.layout_fieldKey] = note; + if (pivotField) { + textDoc[pivotField] = pivotValue; + } + docTextAdder(textDoc); + }, 'create quick note'), + icon: StrCast(note.icon) as IconProp, + })) as ContextMenuProps[], + icon: 'sticky-note', + }); + const userDocList: ContextMenuProps[] = DocListCast(DocListCast(Doc.MyTools?.data)[1]?.data) + .filter(btnDoc => !btnDoc.hidden) + .map(btnDoc => Cast(btnDoc?.dragFactory, Doc, null)) + .filter(doc => doc && doc !== Doc.UserDoc().emptyTrail && doc !== Doc.UserDoc().emptyNote && doc.title) + .map(dragDoc => ({ + description: ':' + StrCast(dragDoc.title).replace('Untitled ', ''), + event: undoable(() => { + const newDoc = DocUtils.delegateDragFactory(dragDoc); + if (newDoc) { + newDoc.author = ClientUtils.CurrentUserEmail(); + newDoc.x = x; + newDoc.y = y; + Doc.SetSelectOnLoad(newDoc); + if (pivotField) { + newDoc[pivotField] = pivotValue; + } + docAdder?.(newDoc); + } + }, StrCast(dragDoc.title)), + icon: Doc.toIcon(dragDoc), + })) as ContextMenuProps[]; + ContextMenu.Instance.addItem({ + description: 'User Templates', + subitems: userDocList, + icon: 'file', + }); + } + + // applies a custom template to a document. the template is identified by it's short name (e.g, slideView not layout_slideView) + + /** + * Applies a template to a Doc and logs the action with the UndoManager + * If the template already exists and has been registered, it can be specified by it's signature name (e.g., 'icon' not 'layout_icon'). + * Alternatively, the signature can be omitted and the template can be provided. + * @param doc the Doc to apply the template to. + * @param creator a function that will create the template if it doesn't exist + * @param templateSignature the signature name for a template that has already been created and registered on the userDoc. (can be "" if template is provide) + * @param template the template to use (optional if templateSignature is provided) + * @returns doc + */ + export function makeCustomViewClicked(doc: Doc, creator: Opt<(documents: Array<Doc>, options: DocumentOptions, id?: string) => Doc>, templateSignature: string = 'custom', template?: Doc) { + const batch = UndoManager.StartBatch('makeCustomViewClicked'); + createCustomView(doc, creator, templateSignature || StrCast(template?.title), template); + batch.end(); + return doc; + } + export function findTemplate(templateName: string, type: string) { + let docLayoutTemplate: Opt<Doc>; + const iconViews = DocListCast(Cast(Doc.UserDoc().template_icons, Doc, null)?.data); + const templBtns = DocListCast(Cast(Doc.UserDoc().template_buttons, Doc, null)?.data); + const noteTypes = DocListCast(Cast(Doc.UserDoc().template_notes, Doc, null)?.data); + const userTypes = DocListCast(Cast(Doc.UserDoc().template_user, Doc, null)?.data); + const clickFuncs = DocListCast(Cast(Doc.UserDoc().template_clickFuncs, Doc, null)?.data); + const allTemplates = iconViews + .concat(templBtns) + .concat(noteTypes) + .concat(userTypes) + .concat(clickFuncs) + .map(btnDoc => (btnDoc.dragFactory as Doc) || btnDoc) + .filter(doc => doc.isTemplateDoc); + // bcz: this is hacky -- want to have different templates be applied depending on the "type" of a document. but type is not reliable and there could be other types of template searches so this should be generalized + // first try to find a template that matches the specific document type (<typeName>_<templateName>). otherwise, fallback to a general match on <templateName> + !docLayoutTemplate && + allTemplates.forEach(tempDoc => { + StrCast(tempDoc.title) === templateName + '_' + type && (docLayoutTemplate = tempDoc); + }); + !docLayoutTemplate && + allTemplates.forEach(tempDoc => { + StrCast(tempDoc.title) === templateName && (docLayoutTemplate = tempDoc); + }); + return docLayoutTemplate; + } + export function createCustomView(doc: Doc, creator: Opt<(documents: Array<Doc>, options: DocumentOptions, id?: string) => Doc>, templateSignature: string = 'custom', docLayoutTemplate?: Doc) { + const templateName = templateSignature.replace(/\(.*\)/, ''); + doc.layout_fieldKey = 'layout_' + (templateSignature || (docLayoutTemplate?.title ?? '')); + // eslint-disable-next-line no-param-reassign + docLayoutTemplate = docLayoutTemplate || findTemplate(templateName, StrCast(doc.isGroup && doc.transcription ? 'transcription' : doc.type)); + + const customName = 'layout_' + templateSignature; + const _width = NumCast(doc._width); + const _height = NumCast(doc._height); + const options = { title: 'data', backgroundColor: StrCast(doc.backgroundColor), _layout_autoHeight: true, _width, x: -_width / 2, y: -_height / 2, _layout_showSidebar: false }; + + if (docLayoutTemplate) { + if (docLayoutTemplate !== doc[customName]) { + Doc.ApplyTemplateTo(docLayoutTemplate, doc, customName, undefined); + } + } else { + let fieldTemplate: Opt<Doc>; + if (doc.data instanceof RichTextField || typeof doc.data === 'string') { + fieldTemplate = Docs.Create.TextDocument('', options); + } else if (doc.data instanceof PdfField) { + fieldTemplate = Docs.Create.PdfDocument('http://www.msn.com', options); + } else if (doc.data instanceof VideoField) { + fieldTemplate = Docs.Create.VideoDocument('http://www.cs.brown.edu', options); + } else if (doc.data instanceof AudioField) { + fieldTemplate = Docs.Create.AudioDocument('http://www.cs.brown.edu', options); + } else if (doc.data instanceof ImageField) { + fieldTemplate = Docs.Create.ImageDocument('http://www.cs.brown.edu', options); + } + const docTemplate = creator?.(fieldTemplate ? [fieldTemplate] : [], { title: customName + '(' + doc.title + ')', isTemplateDoc: true, _width: _width + 20, _height: Math.max(100, _height + 45) }); + fieldTemplate && Doc.MakeMetadataFieldTemplate(fieldTemplate, docTemplate ? Doc.GetProto(docTemplate) : docTemplate); + docTemplate && Doc.ApplyTemplateTo(docTemplate, doc, customName, undefined); + } + } + export function makeCustomView(doc: Doc, custom: boolean, layout: string) { + Doc.setNativeView(doc); + if (custom) { + makeCustomViewClicked(doc, Docs.Create.StackingDocument, layout, undefined); + } + } + export function iconify(doc: Doc) { + const layoutFieldKey = Cast(doc.layout_fieldKey, 'string', null); + DocUtils.makeCustomViewClicked(doc, Docs.Create.StackingDocument, 'icon', undefined); + if (layoutFieldKey && layoutFieldKey !== 'layout' && layoutFieldKey !== 'layout_icon') doc.deiconifyLayout = layoutFieldKey.replace('layout_', ''); + } + + export function pileup(docList: Doc[], x?: number, y?: number, size: number = 55, create: boolean = true) { + runInAction(() => { + docList.forEach((doc, i) => { + const d = doc; + DocUtils.iconify(d); + d.x = Math.cos((Math.PI * 2 * i) / docList.length) * size - size; + d.y = Math.sin((Math.PI * 2 * i) / docList.length) * size - size; + d._timecodeToShow = undefined; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection + }); + }); + if (create) { + const newCollection = Docs.Create.PileDocument(docList, { title: 'pileup', _freeform_noZoom: true, x: (x || 0) - size, y: (y || 0) - size, _width: size * 2, _height: size * 2, dragWhenActive: true, _layout_fitWidth: false }); + newCollection.x = NumCast(newCollection.x) + NumCast(newCollection._width) / 2 - size; + newCollection.y = NumCast(newCollection.y) + NumCast(newCollection._height) / 2 - size; + newCollection._width = newCollection._height = size * 2; + return newCollection; + } + return undefined; + } + export function makeIntoPortal(doc: Doc, layoutDoc: Doc, allLinks: Doc[]) { + const portalLink = allLinks.find(d => d.link_anchor_1 === doc && d.link_relationship === 'portal to:portal from'); + if (!portalLink) { + DocUtils.MakeLink( + doc, + Docs.Create.FreeformDocument([], { + _width: NumCast(layoutDoc._width) + 10, + _height: Math.max(NumCast(layoutDoc._height), NumCast(layoutDoc._width) + 10), + _isLightbox: true, + _layout_fitWidth: true, + title: StrCast(doc.title) + ' [Portal]', + }), + { link_relationship: 'portal to:portal from' } + ); + } + doc.followLinkLocation = OpenWhere.lightbox; + doc.onClick = FollowLinkScript(); + } + + export function LeavePushpin(doc: Doc, annotationField: string) { + if (doc.followLinkToggle) return undefined; + const context = Cast(doc.embedContainer, Doc, null) ?? Cast(doc.annotationOn, Doc, null); + const hasContextAnchor = Doc.Links(doc).some(l => (l.link_anchor_2 === doc && Cast(l.link_anchor_1, Doc, null)?.annotationOn === context) || (l.link_anchor_1 === doc && Cast(l.link_anchor_2, Doc, null)?.annotationOn === context)); + if (context && !hasContextAnchor && (context.type === DocumentType.VID || context.type === DocumentType.WEB || context.type === DocumentType.PDF || context.type === DocumentType.IMG)) { + const pushpin = Docs.Create.FontIconDocument({ + title: '', + annotationOn: Cast(doc.annotationOn, Doc, null), + followLinkToggle: true, + icon: 'map-pin', + x: Cast(doc.x, 'number', null), + y: Cast(doc.y, 'number', null), + backgroundColor: '#ACCEF7', + layout_hideAllLinks: true, + _width: 15, + _height: 15, + _xPadding: 0, + onClick: FollowLinkScript(), + _timecodeToShow: Cast(doc._timecodeToShow, 'number', null), + }); + Doc.AddDocToList(context, annotationField, pushpin); + DocUtils.MakeLink(pushpin, doc, { link_relationship: 'pushpin' }, ''); + doc._timecodeToShow = undefined; + return pushpin; + } + return undefined; + } + + // /** + // * + // * @param dms Degree Minute Second format exif gps data + // * @param ref ref that determines negativity of decimal coordinates + // * @returns a decimal format of gps latitude / longitude + // */ + // function getDecimalfromDMS(dms?: number[], ref?: string) { + // if (dms && ref) { + // let degrees = dms[0] / dms[1]; + // let minutes = dms[2] / dms[3] / 60.0; + // let seconds = dms[4] / dms[5] / 3600.0; + + // if (['S', 'W'].includes(ref)) { + // degrees = -degrees; minutes = -minutes; seconds = -seconds + // } + // return (degrees + minutes + seconds).toFixed(5); + // } + // } + + function ConvertDMSToDD(degrees: number, minutes: number, seconds: number, direction: string) { + let dd = degrees + minutes / 60 + seconds / (60 * 60); + if (direction === 'S' || direction === 'W') { + dd *= -1; + } // Don't do anything for N or E + return dd; + } + + export function assignImageInfo(result: Upload.FileInformation, protoIn: Doc) { + const proto = protoIn; + if (Upload.isImageInformation(result)) { + const maxNativeDim = Math.min(Math.max(result.nativeHeight, result.nativeWidth), defaultNativeImageDim); + const exifRotation = StrCast((result.exifData?.data as any)?.Orientation).toLowerCase(); + proto.data_nativeOrientation = result.exifData?.data?.image?.Orientation ?? (exifRotation.includes('rotate 90') || exifRotation.includes('rotate 270') ? 5 : undefined); + proto.data_nativeWidth = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim; + proto.data_nativeHeight = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); + if (NumCast(proto.data_nativeOrientation) >= 5) { + proto.data_nativeHeight = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim; + proto.data_nativeWidth = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); + } + proto.data_exif = JSON.stringify(result.exifData?.data); + proto.data_contentSize = result.contentSize; + // exif gps data coordinates are stored in DMS (Degrees Minutes Seconds), the following operation converts that to decimal coordinates + const latitude = result.exifData?.data?.GPSLatitude; + const latitudeDirection = result.exifData?.data?.GPSLatitudeRef; + const longitude = result.exifData?.data?.GPSLongitude; + const longitudeDirection = result.exifData?.data?.GPSLongitudeRef; + if (latitude !== undefined && longitude !== undefined && latitudeDirection !== undefined && longitudeDirection !== undefined) { + proto.latitude = ConvertDMSToDD(latitude[0], latitude[1], latitude[2], latitudeDirection); + proto.longitude = ConvertDMSToDD(longitude[0], longitude[1], longitude[2], longitudeDirection); + } + } + } + + async function processFileupload(generatedDocuments: Doc[], name: string, type: string, result: Error | Upload.FileInformation, options: DocumentOptions, overwriteDoc?: Doc) { + if (result instanceof Error) { + alert(`Upload failed: ${result.message}`); + return; + } + const full = { ...options, _width: 400, title: name }; + const pathname = result.accessPaths.agnostic.client; + const doc = await DocUtils.DocumentFromType(type, pathname, full, overwriteDoc); + if (doc) { + const proto = Doc.GetProto(doc); + proto.text = result.rawText; + !(result instanceof Error) && DocUtils.assignImageInfo(result, proto); + if (Upload.isVideoInformation(result)) { + proto.data_duration = result.duration; + } + if (overwriteDoc) { + Doc.removeCurrentlyLoading(overwriteDoc); + } + generatedDocuments.push(doc); + } + } + + export function GetNewTextDoc(title: string, x: number, y: number, width?: number, height?: number, annotationOn?: Doc, backgroundColor?: string) { + const defaultTextTemplate = DocCast(Doc.UserDoc().defaultTextLayout); + const tbox = Docs.Create.TextDocument('', { + annotationOn, + backgroundColor, + x, + y, + title, + ...(defaultTextTemplate + ? {} // if the new doc will inherit from a template, don't set any layout fields since that would block the inheritance + : { + _width: width || 200, + _height: 35, + _layout_centered: BoolCast(Doc.UserDoc()._layout_centered), + _layout_fitWidth: true, + _layout_autoHeight: true, + }), + }); + + if (defaultTextTemplate) { + tbox.layout_fieldKey = 'layout_' + StrCast(defaultTextTemplate.title); + Doc.GetProto(tbox)[StrCast(tbox.layout_fieldKey)] = defaultTextTemplate; // set the text doc's layout to render with the text template + tbox[DocData].proto = defaultTextTemplate; // and also set the text doc to inherit from the template (this allows the template to specify default field values) + } + return tbox; + } + + export function uploadYoutubeVideoLoading(videoId: string, options: DocumentOptions, overwriteDoc?: Doc) { + const generatedDocuments: Doc[] = []; + Networking.UploadYoutubeToServer(videoId, overwriteDoc?.[Id]).then(upfiles => { + const { + source: { newFilename, mimetype }, + result, + } = upfiles.lastElement(); + if ((result as any).message) { + if (overwriteDoc) { + overwriteDoc.isLoading = false; + overwriteDoc.loadingError = (result as any).message; + Doc.removeCurrentlyLoading(overwriteDoc); + } + } else newFilename && processFileupload(generatedDocuments, newFilename, mimetype ?? '', result, options, overwriteDoc); + }); + } + + /** + * uploadFilesToDocs will take in an array of Files, and creates documents for the + * new files. + * + * @param files an array of files that will be uploaded + * @param options options to use while uploading + * @returns + */ + export async function uploadFilesToDocs(files: File[], options: DocumentOptions) { + const generatedDocuments: Doc[] = []; + + // These files do not have overwriteDocs, so we do not set the guid and let the client generate one. + const fileNoGuidPairs: Networking.FileGuidPair[] = files.map(file => ({ file })); + + const upfiles = await Networking.UploadFilesToServer(fileNoGuidPairs); + upfiles.forEach(({ source: { newFilename, mimetype }, result }) => { + newFilename && mimetype && processFileupload(generatedDocuments, newFilename, mimetype, result, options); + }); + return generatedDocuments; + } + + export function uploadFileToDoc(file: File, options: DocumentOptions, overwriteDoc: Doc) { + const generatedDocuments: Doc[] = []; + // Since this file has an overwriteDoc, we can set the client tracking guid to the overwriteDoc's guid. + Networking.UploadFilesToServer([{ file, guid: overwriteDoc[Id] }]).then(upfiles => { + const { + source: { newFilename, mimetype }, + result, + } = upfiles.lastElement() ?? { source: { newFilename: '', mimetype: '' }, result: { message: 'upload failed' } }; + if ((result as any).message) { + if (overwriteDoc) { + overwriteDoc.loadingError = (result as any).message; + Doc.removeCurrentlyLoading(overwriteDoc); + } + } else newFilename && mimetype && processFileupload(generatedDocuments, newFilename, mimetype, result, options, overwriteDoc); + }); + } + + // copies the specified drag factory document + export function copyDragFactory(dragFactory: Doc) { + if (!dragFactory) return undefined; + const ndoc = dragFactory.isTemplateDoc ? Doc.ApplyTemplate(dragFactory) : Doc.MakeCopy(dragFactory, true); + if (ndoc && dragFactory.dragFactory_count !== undefined) { + dragFactory.dragFactory_count = NumCast(dragFactory.dragFactory_count) + 1; + Doc.SetInPlace(ndoc, 'title', ndoc.title + ' ' + NumCast(dragFactory.dragFactory_count).toString(), true); + } + + return ndoc; + } + export function delegateDragFactory(dragFactory: Doc) { + const ndoc = Doc.MakeDelegateWithProto(dragFactory); + if (ndoc && dragFactory.dragFactory_count !== undefined) { + dragFactory.dragFactory_count = NumCast(dragFactory.dragFactory_count) + 1; + Doc.GetProto(ndoc).title = ndoc.title + ' ' + NumCast(dragFactory.dragFactory_count).toString(); + } + return ndoc; + } + + export async function Zip(doc: Doc, zipFilename = 'dashExport.zip') { + const { clone, map, linkMap } = await Doc.MakeClone(doc); + const proms = new Set<string>(); + function replacer(key: any, value: any) { + if (key && ['branchOf', 'cloneOf', 'cursors'].includes(key)) return undefined; + if (value?.__type === 'image') { + const extension = value.url.replace(/.*\./, ''); + proms.add(value.url.replace('.' + extension, '_o.' + extension)); + return SerializationHelper.Serialize(new ImageField(value.url)); + } + if (value?.__type === 'pdf') { + proms.add(value.url); + return SerializationHelper.Serialize(new PdfField(value.url)); + } + if (value?.__type === 'audio') { + proms.add(value.url); + return SerializationHelper.Serialize(new AudioField(value.url)); + } + if (value?.__type === 'video') { + proms.add(value.url); + return SerializationHelper.Serialize(new VideoField(value.url)); + } + if ( + value instanceof Doc || + value instanceof ScriptField || + value instanceof RichTextField || + value instanceof InkField || + value instanceof CsvField || + value instanceof WebField || + value instanceof DateField || + value instanceof ProxyField || + value instanceof ComputedField + ) { + return SerializationHelper.Serialize(value); + } + if (value instanceof Array && key !== ListFieldName && key !== InkDataFieldName) return { fields: value, __type: 'list' }; + return value; + } + + const docs: { [id: string]: any } = {}; + const links: { [id: string]: any } = {}; + Array.from(map.entries()).forEach(f => { + docs[f[0]] = f[1]; + }); + Array.from(linkMap.entries()).forEach(l => { + links[l[0]] = l[1]; + }); + const jsonDocs = JSON.stringify({ id: clone[Id], docs, links }, decycle(replacer)); + + const zip = new JSZip(); + let count = 0; + const promArr = Array.from(proms) + .filter(url => url?.startsWith('/files')) + .map(url => url.replace('/', '')); // window.location.origin)); + console.log(promArr.length); + if (!promArr.length) { + zip.file('docs.json', jsonDocs); + zip.generateAsync({ type: 'blob' }).then(content => saveAs(content, zipFilename)); + } else + promArr.forEach((url, i) => { + // loading a file and add it in a zip file + JSZipUtils.getBinaryContent(window.location.origin + '/' + url, (err: any, data: any) => { + if (err) throw err; // or handle the error + // // Generate a directory within the Zip file structure + // const assets = zip.folder("assets"); + // assets.file(filename, data, {binary: true}); + const assetPathOnServer = promArr[i].replace(window.location.origin + '/', '').replace(/\//g, '%%%'); + zip.file(assetPathOnServer, data, { binary: true }); + console.log(' => ' + url); + if (++count === promArr.length) { + zip.file('docs.json', jsonDocs); + zip.generateAsync({ type: 'blob' }).then(content => saveAs(content, zipFilename)); + // const a = document.createElement("a"); + // const url = Utils.prepend(`/downloadId/${this.props.Document[Id]}`); + // a.href = url; + // a.download = `DocExport-${this.props.Document[Id]}.zip`; + // a.click(); + } + }); + }); + } +} + +export function FollowLinkScript() { + return ScriptField.MakeScript('return followLink(this,altKey)', { altKey: 'boolean' }); +} + +export function IsFollowLinkScript(field: FieldResult<FieldType>) { + return ScriptCast(field)?.script.originalScript.includes('return followLink('); +} + +ScriptingGlobals.add('Docs', Docs); +// eslint-disable-next-line prefer-arrow-callback +ScriptingGlobals.add(function copyDragFactory(dragFactory: Doc, asDelegate?: boolean) { + return dragFactory instanceof Doc ? (asDelegate ? DocUtils.delegateDragFactory(dragFactory) : DocUtils.copyDragFactory(dragFactory)) : dragFactory; +}); +// eslint-disable-next-line prefer-arrow-callback +ScriptingGlobals.add(function makeDelegate(proto: any) { + const d = Docs.Create.DelegateDocument(proto, { title: 'child of ' + proto.title }); + return d; +}); +// eslint-disable-next-line prefer-arrow-callback +ScriptingGlobals.add(function generateLinkTitle(link: Doc) { + const linkAnchor1title = link.link_anchor_1 && link.link_anchor_1 !== link ? Cast(link.link_anchor_1, Doc, null)?.title : '<?>'; + const linkAnchor2title = link.link_anchor_2 && link.link_anchor_2 !== link ? Cast(link.link_anchor_2, Doc, null)?.title : '<?>'; + const relation = link.link_relationship || 'to'; + return `${linkAnchor1title} (${relation}) ${linkAnchor2title}`; +}); |