aboutsummaryrefslogtreecommitdiff
path: root/src/client/documents/DocUtils.ts
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2024-05-17 14:55:36 -0400
committerbobzel <zzzman@gmail.com>2024-05-17 14:55:36 -0400
commit0b451af28e5aef6b749da61e8a9fcd0a840789ac (patch)
treebdee4e28ee4715b69299a8da1b615c70b6adc445 /src/client/documents/DocUtils.ts
parent8c1b420a143e4b72ec551277887c211ca6ca003b (diff)
parent38a382a03675d6a50ec7de75f05025efd093f570 (diff)
merged with new master
Diffstat (limited to 'src/client/documents/DocUtils.ts')
-rw-r--r--src/client/documents/DocUtils.ts875
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}`;
+});