import { action, observable, observe } from 'mobx'; import { computedFn } from 'mobx-utils'; import { Doc, DocListCast, DocListCastAsync, Field, Opt } from '../../fields/Doc'; import { DirectLinks } from '../../fields/DocSymbols'; import { List } from '../../fields/List'; import { ProxyField } from '../../fields/Proxy'; import { Cast, DocCast, PromiseValue, StrCast } from '../../fields/Types'; import { DocServer } from '../DocServer'; import { ScriptingGlobals } from './ScriptingGlobals'; /* * link doc: * - link_anchor_1: doc * - link_anchor_2: doc * * group doc: * - type: string representing the group type/name/category * - metadata: doc representing the metadata kvps * * metadata doc: * - user defined kvps */ export class LinkManager { @observable static _instance: LinkManager; @observable static userLinkDBs: Doc[] = []; @observable public static currentLink: Opt; @observable public static currentLinkAnchor: Opt; public static get Instance() { return LinkManager._instance; } public static Links(doc: Doc | undefined) { return doc ? LinkManager.Instance.getAllRelatedLinks(doc) : []; } public static addLinkDB = async (linkDb: any) => { await Promise.all( ((await DocListCastAsync(linkDb.data)) ?? []).map(link => // makes sure link anchors are loaded to avoid incremental updates to computedFns in LinkManager [PromiseValue(link?.link_anchor_1), PromiseValue(link?.link_anchor_2)] ) ); LinkManager.userLinkDBs.push(linkDb); }; public static AutoKeywords = 'keywords:Usages'; static _links: Doc[] = []; constructor() { LinkManager._instance = this; this.createlink_relationshipLists(); LinkManager.userLinkDBs = []; const addLinkToDoc = (link: Doc) => { Promise.all([link]).then(linkdoc => { const link = DocCast(linkdoc[0]); Promise.all([link.proto]).then(linkproto => { Promise.all([link.link_anchor_1, link.link_anchor_2]).then(linkdata => { const a1 = DocCast(linkdata[0]); const a2 = DocCast(linkdata[1]); a1 && a2 && Promise.all([Doc.GetProto(a1), Doc.GetProto(a2)]).then( action(protos => { (protos[0] as Doc)?.[DirectLinks].add(link); (protos[1] as Doc)?.[DirectLinks].add(link); }) ); }); }); }); }; const remLinkFromDoc = (link: Doc) => { const a1 = link?.link_anchor_1; const a2 = link?.link_anchor_2; Promise.all([a1, a2]).then( action(() => { if (a1 instanceof Doc && a2 instanceof Doc && ((a1.author !== undefined && a2.author !== undefined) || link.author === Doc.CurrentUserEmail)) { Doc.GetProto(a1)[DirectLinks].delete(link); Doc.GetProto(a2)[DirectLinks].delete(link); Doc.GetProto(link)[DirectLinks].delete(link); } }) ); }; const watchUserLinkDB = (userLinkDBDoc: Doc) => { LinkManager._links.push(...DocListCast(userLinkDBDoc.data)); const toRealField = (field: Field) => (field instanceof ProxyField ? field.value : field); // see List.ts. data structure is not a simple list of Docs, but a list of ProxyField/Fields if (userLinkDBDoc.data) { observe( userLinkDBDoc.data, change => { // observe pushes/splices on a user link DB 'data' field (should only happen for local changes) switch (change.type as any) { case 'splice': (change as any).added.forEach((link: any) => addLinkToDoc(toRealField(link))); (change as any).removed.forEach((link: any) => remLinkFromDoc(toRealField(link))); break; case 'update': //let oldValue = change.oldValue; } }, true ); observe( userLinkDBDoc, 'data', // obsever when a new array of links is assigned as the link DB 'data' field (should happen whenever a remote user adds/removes a link) change => { switch (change.type as any) { case 'update': Promise.all([...((change.oldValue as any as Doc[]) || []), ...((change.newValue as any as Doc[]) || [])]).then(doclist => { const oldDocs = doclist.slice(0, ((change.oldValue as any as Doc[]) || []).length); const newDocs = doclist.slice(((change.oldValue as any as Doc[]) || []).length, doclist.length); const added = newDocs?.filter(link => !(oldDocs || []).includes(link)); const removed = oldDocs?.filter(link => !(newDocs || []).includes(link)); added?.forEach((link: any) => addLinkToDoc(toRealField(link))); removed?.forEach((link: any) => remLinkFromDoc(toRealField(link))); }); } }, true ); } }; observe( LinkManager.userLinkDBs, change => { switch (change.type as any) { case 'splice': (change as any).added.forEach(watchUserLinkDB); break; case 'update': //let oldValue = change.oldValue; } }, true ); LinkManager.addLinkDB(Doc.LinkDBDoc()); } public createlink_relationshipLists = () => { //create new lists for link relations and their associated colors if the lists don't already exist !Doc.UserDoc().link_relationshipList && (Doc.UserDoc().link_relationshipList = new List()); !Doc.UserDoc().link_ColorList && (Doc.UserDoc().link_ColorList = new List()); !Doc.UserDoc().link_relationshipSizes && (Doc.UserDoc().link_relationshipSizes = new List()); }; public addLink(linkDoc: Doc, checkExists = false) { if (!checkExists || !DocListCast(Doc.LinkDBDoc().data).includes(linkDoc)) { Doc.AddDocToList(Doc.LinkDBDoc(), 'data', linkDoc); setTimeout(DocServer.UPDATE_SERVER_CACHE, 100); } } public deleteLink(linkDoc: Doc) { return Doc.RemoveDocFromList(Doc.LinkDBDoc(), 'data', linkDoc); } public deleteAllLinksOnAnchor(anchor: Doc) { LinkManager.Instance.relatedLinker(anchor).forEach(linkDoc => LinkManager.Instance.deleteLink(linkDoc)); } public getAllRelatedLinks(anchor: Doc) { return this.relatedLinker(anchor); } // finds all links that contain the given anchor public getAllDirectLinks(anchor: Doc): Doc[] { return Array.from(Doc.GetProto(anchor)[DirectLinks] ?? []); } // finds all links that contain the given anchor relatedLinker = computedFn(function relatedLinker(this: any, anchor: Doc): Doc[] { if (!anchor || anchor instanceof Promise || Doc.GetProto(anchor) instanceof Promise) { console.log('WAITING FOR DOC/PROTO IN LINKMANAGER'); return []; } const dirLinks = Doc.GetProto(anchor)[DirectLinks]; const annos = DocListCast(anchor[Doc.LayoutFieldKey(anchor) + '_annotations']); if (!annos) debugger; return annos.reduce((list, anno) => [...list, ...LinkManager.Instance.relatedLinker(anno)], Array.from(dirLinks).slice()); }, true); // returns map of group type to anchor's links in that group type public getRelatedGroupedLinks(anchor: Doc): Map> { const anchorGroups = new Map>(); this.relatedLinker(anchor).forEach(link => { if (link.link_relationship && link.link_relationship !== '-ungrouped-') { const relation = StrCast(link.link_relationship); const anchorRelation = relation.indexOf(':') !== -1 ? relation.split(':')[Doc.AreProtosEqual(Cast(link.link_anchor_1, Doc, null), anchor) ? 0 : 1] : relation; const group = anchorGroups.get(anchorRelation); anchorGroups.set(anchorRelation, group ? [...group, link] : [link]); } else { // if link is in no groups then put it in default group const group = anchorGroups.get('*'); anchorGroups.set('*', group ? [...group, link] : [link]); } }); return anchorGroups; } // finds the opposite anchor of a given anchor in a link //TODO This should probably return undefined if there isn't an opposite anchor //TODO This should also await the return value of the anchor so we don't filter out promises public static getOppositeAnchor(linkDoc: Doc, anchor: Doc): Doc | undefined { const a1 = Cast(linkDoc.link_anchor_1, Doc, null); const a2 = Cast(linkDoc.link_anchor_2, Doc, null); if (Doc.AreProtosEqual(anchor, a1)) return a2; if (Doc.AreProtosEqual(anchor, a2)) return a1; if (Doc.AreProtosEqual(anchor, a1.annotationOn as Doc)) return a2; if (Doc.AreProtosEqual(anchor, a2.annotationOn as Doc)) return a1; if (Doc.AreProtosEqual(anchor, linkDoc)) return linkDoc; } } ScriptingGlobals.add( function links(doc: any) { return new List(LinkManager.Links(doc)); }, 'returns all the links to the document or its annotations', '(doc: any)' );