import {Injectable} from '@angular/core'; import {ApiService, ResourceNotAvailableOfflineError} from './api.service'; import PageRecord, {PageVersionRecord} from '../structures/PageRecord'; import HostRecord from '../structures/HostRecord'; import {EditorNodeContract} from '../components/nodes/EditorNode.contract'; import {BehaviorSubject, Subscription} from 'rxjs'; import {NavigationService} from './navigation.service'; import {DatabaseService} from './db/database.service'; import {Page} from './db/Page'; import {PageNode} from './db/PageNode'; import {MenuItem} from './db/MenuItem'; import {SessionService} from './session.service'; import {debounce, uuid_v4} from '../utility'; export class NoPageLoadedError extends Error { constructor(msg = 'There is no page open for editing.') { super(msg); } } @Injectable({ providedIn: 'root' }) export class EditorService { private static instances: {[key: string]: EditorService} = {}; protected currentPage?: PageRecord; protected currentNodes: HostRecord[] = []; protected nodeIdToEditorContract: { [key: string]: EditorNodeContract } = {}; protected dirtyOverride = false; protected ready$: BehaviorSubject = new BehaviorSubject(false); protected subs: Subscription[] = []; protected saving = false; protected currentPageVersion?: number; public forceReadonly = false; protected saveTriggered = false; public notAvailable = false; public readonly instanceUUID: string; protected privTriggerSave = debounce(() => { if ( this.saving ) { this.triggerSave(); } else { this.save(); } this.saveTriggered = false; }, 3000); public static registerInstance(inst: EditorService) { this.instances[inst.instanceUUID] = inst; } public triggerSave() { this.saveTriggered = true; this.privTriggerSave(); } public get currentPageId() { return this.currentPage?.UUID; } public get isSaving() { return this.saving; } public get willSave() { return this.saveTriggered; } public get isEditing() { return !!this.currentPage; } public get immutableNodes(): HostRecord[] { return [...this.currentNodes]; } public get mutablePageName(): string { if ( this.currentPage ) { return this.currentPage.Name; } return ''; } public set mutablePageName(name: string) { if ( this.currentPage && this.canEdit() ) { if ( this.currentPage.Name !== name ) { this.dirtyOverride = true; this.triggerSave(); } this.currentPage.Name = name; } } public get currentPageType() { return this.currentPage?.PageType; } constructor( protected api: ApiService, protected nav: NavigationService, protected db: DatabaseService, protected session: SessionService, ) { this.instanceUUID = uuid_v4(); console.log('editor service', this); } getEditor(uuid?: string) { if ( uuid ) { return EditorService.instances[uuid]; } const inst = new EditorService(this.api, this.nav, this.db, this.session); EditorService.registerInstance(inst); return inst; } async reload() { await this.startEditing(this.currentPageId); } async startEditing(pageId: string, version?: number) { if ( this.currentPage ) { await this.stopEditing(); } try { this.currentPageVersion = version; this.currentPage = await this.loadPage(pageId, version); this.currentNodes = await this.loadNodes(pageId, version); this.notAvailable = false; await this.ready$.next(true); } catch (e) { if ( e instanceof ResourceNotAvailableOfflineError ) { this.notAvailable = true; await this.ready$.next(true); } else { throw e; } } } async stopEditing() { delete this.currentPage; this.currentNodes = []; this.nodeIdToEditorContract = {}; this.subs.forEach(sub => sub.unsubscribe()); this.subs = []; this.ready$.next(false); } async save() { if ( !(await this.needsSave()) || this.saving || this.forceReadonly ) { return; } this.saving = true; const editors = Object.values(this.nodeIdToEditorContract); // Save all editors that handle their data independently first await Promise.all(editors.map(async editor => { if ( await editor.needsSave() ) { await editor.performSave(); } })); // Tell the editors to write their state changes to the HostRecords await Promise.all(editors.map(async editor => { await editor.writeChangesToNode(); })); await this.savePage(this.currentPage); await this.saveNodesAsPage(this.currentPage, this.currentNodes); this.dirtyOverride = false; this.saving = false; this.nav.requestSidebarRefresh({ quiet: true }); } async moveNode(node: HostRecord, direction: 'up' | 'down') { if ( !this.currentPage ) { throw new NoPageLoadedError(); } if ( this.forceReadonly ) { return; } const nodeIndex = this.currentNodes.findIndex(maybeNode => maybeNode.UUID === node.UUID); if ( nodeIndex < 0 ) { return; } if ( direction === 'up' && nodeIndex > 0 ) { const otherIdx = nodeIndex - 1; const otherNode = this.currentNodes[otherIdx]; this.currentNodes[otherIdx] = this.currentNodes[nodeIndex]; this.currentNodes[nodeIndex] = otherNode; } else if ( direction === 'down' && nodeIndex !== (this.currentNodes.length - 1) ) { const otherIdx = nodeIndex + 1; const otherNode = this.currentNodes[otherIdx]; this.currentNodes[otherIdx] = this.currentNodes[nodeIndex]; this.currentNodes[nodeIndex] = otherNode; } this.dirtyOverride = true; this.triggerSave(); } async savePage(page: PageRecord): Promise { await new Promise(async (res, rej) => { const existingLocalPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page; const saveData = page.toSave(); if ( this.api.isOffline ) { if ( existingLocalPage ) { existingLocalPage.fillFromRecord(page); existingLocalPage.UpdatedAt = String(new Date()); // FIXME update UpdateUserId existingLocalPage.needsServerUpdate = 1; await existingLocalPage.save(); } else { const newLocalPage = new Page( page.UUID, page.Name, page.OrgUserId, page.IsPublic, page.IsVisibleInMenu, page.ParentId, page.NodeIds, String(page.CreatedAt || new Date()), String(new Date()), true, page.CreatedUserId, page.UpdateUserId, // FIXME fill in the current user's ID page.ChildPageIds, false, false, 1, ); await newLocalPage.save(); } return res(); } this.api.post(`/page/${page.UUID}/save`, saveData).subscribe({ next: async result => { if ( existingLocalPage ) { existingLocalPage.fillFromRecord(page); await existingLocalPage.save(); } else { const newLocalPage = new Page( result.data.UUID, result.data.Name, result.data.OrgUserId, result.data.IsPublic, result.data.IsVisibleInMenu, result.data.ParentId, result.data.NodeIds, String(result.data.CreatedAt), String(result.data.UpdatedAt), true, result.data.CreatedUserId, result.data.UpdateUserId, result.data.ChildPageIds, result.data.noDelete, result.data.virtual, 0 ); await newLocalPage.save(); } res(); }, error: rej, }); }); } async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]): Promise { return new Promise(async (res, rej) => { const saveNodes = nodes.map(x => { x.PageId = page.UUID; return x.toSave(); }); const existingLocalPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page; // If we're offline save the nodes locally if ( this.api.isOffline ) { if ( !existingLocalPage ) { return rej(new ResourceNotAvailableOfflineError()); } const nodeRecs: PageNode[] = []; for ( const nodeRec of saveNodes ) { const existingLocalNode = await this.db.pageNodes.where({ UUID: nodeRec.UUID }).first() as PageNode; if ( existingLocalNode ) { existingLocalNode.fillFromRecord(nodeRec); existingLocalNode.needsServerUpdate = true; await existingLocalNode.save(); nodeRecs.push(existingLocalNode); } else { const newLocalNode = new PageNode( nodeRec.UUID || PageNode.getUUID(), nodeRec.Type, JSON.stringify(nodeRec.Value), nodeRec.PageId, nodeRec.CreatedAt, nodeRec.UpdatedAt, nodeRec.CreatedUserId, nodeRec.UpdateUserId, true ); await newLocalNode.save(); nodeRecs.push(newLocalNode); } } page.NodeIds = nodeRecs.map(x => x.UUID); existingLocalPage.NodeIds = nodeRecs.map(x => x.UUID); existingLocalPage.needsServerUpdate = 1; await existingLocalPage.save(); return res(nodeRecs.map(x => { const rec = x.inflateToRecord(); const host = new HostRecord(rec.Value.Value); host.load(rec); return host; })); } // Otherwise, use the server to save them and update the local records this.api.post(`/page/${page.UUID}/nodes/save`, saveNodes).subscribe({ next: async result => { await this.db.pageNodes.where({ PageId: page.UUID }).delete(); const returns = []; for ( const rec of result.data ) { const newLocalNode = new PageNode( rec.UUID, rec.Type, JSON.stringify(rec.Value), rec.PageId, rec.CreatedAt, rec.UpdatedAt, rec.CreatedUserId, rec.UpdateUsetId, ); await newLocalNode.save(); const host = new HostRecord(rec.Value.Value); host.load(rec); returns.push(host); } return res(returns); }, error: rej, }); }); } async saveNodeToPage(page: PageRecord, node: HostRecord): Promise { return new Promise(async (res, rej) => { node.PageId = page.UUID; const nodeData = node.toSave(); const localPage = await this.db.pages.where({ UUID: page.UUID }).first() as Page; if ( this.api.isOffline ) { if ( !localPage ) { return rej(new ResourceNotAvailableOfflineError()); } if ( !nodeData.UUID ) { nodeData.UUID = PageNode.getUUID(); } const newLocalNode = new PageNode( nodeData.UUID, nodeData.Type, JSON.stringify(nodeData.Value), nodeData.PageId, nodeData.CreatedAt, nodeData.UpdatedAt, nodeData.CreatedUserId, nodeData.UpdateUserId, true ); await newLocalNode.save(); localPage.NodeIds.push(newLocalNode.UUID); localPage.needsServerUpdate = 1; await localPage.save(); const host = new HostRecord(nodeData.Value.Value); host.load(nodeData); return res(host); } this.api.post(`/page/${page.UUID}/nodes/save_one`, { nodeData }).subscribe({ next: async result => { const newLocalNode = new PageNode( result.data.UUID, result.data.Type, JSON.stringify(result.data.Value), result.data.PageId, result.data.CreatedAt, result.data.UpdatedAt, result.data.CreatedUserId, result.data.UpdateUserId ); await newLocalNode.save(); if ( localPage ) { localPage.NodeIds.push(result.data.UUID); await localPage.save(); } const host = new HostRecord(result.data.Value.Value); host.load(result.data); res(host); }, error: rej, }); }); } async needsSave() { if ( this.forceReadonly ) { return false; } if ( this.dirtyOverride ) { return true; } const dirties = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.isDirty())); const needSaves = await Promise.all(Object.values(this.nodeIdToEditorContract).map(editor => editor.needsSave())); return dirties.some(Boolean) || needSaves.some(Boolean); } async deleteNode(nodeId: string) { if ( !this.currentPage ) { throw new NoPageLoadedError(); } if ( this.forceReadonly ) { return; } const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId); if ( !node ) { throw new Error('Invalid node ID.'); } const editor = this.nodeIdToEditorContract[nodeId]; if ( editor ) { await editor.performDelete(); delete this.nodeIdToEditorContract[nodeId]; } // If we're offline, we need to flag the local node record for deletion if ( this.api.isOffline ) { const existingLocalNode = await this.db.pageNodes.where({ UUID: nodeId }).first() as PageNode; if ( existingLocalNode ) { existingLocalNode.deleted = true; existingLocalNode.needsServerUpdate = true; await existingLocalNode.save(); } } this.currentNodes = this.currentNodes.filter(x => x.UUID !== nodeId); this.dirtyOverride = true; this.triggerSave(); } async addNode(type: 'paragraph' | 'code_ref' | 'database_ref' | 'file_ref', position?: 'before' | 'after', positionNodeId?: string) { if ( !this.currentPage ) { throw new NoPageLoadedError(); } if ( this.forceReadonly ) { return; } const baseHost = new HostRecord(); baseHost.type = type; baseHost.PageId = this.currentPage.UUID; const host = await this.saveNodeToPage(this.currentPage, baseHost); let placed = false; if ( position === 'before' && positionNodeId ) { const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId); if ( index > -1 ) { this.currentNodes.splice(index, 0, host); placed = true; } } else if ( position === 'after' && positionNodeId ) { const index = this.currentNodes.findIndex(node => node.UUID === positionNodeId); if ( index > -1 ) { this.currentNodes.splice(index + 1, 0, host); placed = true; } } if ( !placed ) { this.currentNodes.push(host); } this.currentPage.NodeIds.push(host.UUID); this.dirtyOverride = true; this.triggerSave(); return host; } canEdit() { return this.currentPage && !this.currentPage.isViewOnly() && !this.forceReadonly; } async registerNodeEditor(nodeId: string, editor: EditorNodeContract) { return new Promise((res, rej) => { const sub = this.ready$.subscribe(async val => { if ( val ) { try { if ( !this.currentPage ) { return rej(new NoPageLoadedError()); } const node = this.currentNodes.find(maybeNode => maybeNode.UUID === nodeId); if ( !node ) { return rej(new Error('Invalid node ID.')); } editor.page = this.currentPage; editor.node = node; this.nodeIdToEditorContract[nodeId] = editor; if ( editor.needsLoad() ) { await editor.performLoad(); } res(); } catch (e) { rej(e); } } }); this.subs.push(sub); }); } async unregisterNodeEditor(nodeId: string) { if ( !this.currentPage ) { throw new NoPageLoadedError(); } delete this.nodeIdToEditorContract[nodeId]; } async loadPage(pageId: string, version?: number): Promise { return new Promise(async (res, rej) => { const existingLocalPage = await this.db.pages.where({ UUID: pageId }).first() as Page; // If we're offline, return the local record, or throw an error. if ( this.api.isOffline ) { if ( existingLocalPage ) { return res(new PageRecord(existingLocalPage.getSaveRecord())); } else { return rej(new ResourceNotAvailableOfflineError()); } } // If we're online, fetch the page record and store it locally this.api.get(`/page/${pageId}${version ? '?version=' + version : ''}`).subscribe({ next: async result => { const page = new PageRecord(result.data); if ( existingLocalPage ) { existingLocalPage.fillFromRecord(result.data); existingLocalPage.needsServerUpdate = 0; await existingLocalPage.save(); } else { const newLocalPage = new Page( page.UUID, page.Name, page.OrgUserId, page.IsPublic, page.IsVisibleInMenu, page.ParentId, page.NodeIds, String(page.CreatedAt), String(page.UpdatedAt), true, page.CreatedUserId, page.UpdateUserId, page.ChildPageIds, result.data.noDelete, result.data.virtual, ); await newLocalPage.save(); } res(page); }, error: rej, }); }); } async loadPageVersions(pageId: string): Promise { return new Promise(async (res, rej) => { if ( this.api.isOffline ) { return rej(new ResourceNotAvailableOfflineError()); } this.api.get(`/page/${pageId}/versions`).subscribe({ next: results => { return res(results.data.map(data => { return { currentVersion: Boolean(data.current_version), versionNum: Number(data.version_num), versionUserId: data.version_user_id, versionMessage: data.version_message, versionUUID: data.version_UUID, versionCreateDate: new Date(data.version_create_date), userDisplay: data.user_display, }; })); }, error: rej, }); }); } async revertPageToVersion(pageId: string, versionNum: number): Promise { return new Promise(async (res, rej) => { const existingLocalPage = await this.db.pages.where({ UUID: pageId }).first() as Page; if ( this.api.isOffline ) { return rej(new ResourceNotAvailableOfflineError()); } this.api.post(`/page/${pageId}/versions/revert`, { version_num: versionNum }).subscribe({ next: async result => { const page = new PageRecord(result.data); if ( existingLocalPage ) { existingLocalPage.fillFromRecord(result.data); existingLocalPage.needsServerUpdate = 0; await existingLocalPage.save(); } else { const newLocalPage = new Page( page.UUID, page.Name, page.OrgUserId, page.IsPublic, page.IsVisibleInMenu, page.ParentId, page.NodeIds, String(page.CreatedAt), String(page.UpdatedAt), true, page.CreatedUserId, page.UpdateUserId, page.ChildPageIds, result.data.noDelete, result.data.virtual, ); await newLocalPage.save(); } res(page); }, error: rej, }); }); } async loadNodes(pageId: string, version?: number): Promise { return new Promise(async (res, rej) => { const existingNodes = await this.db.pageNodes.where({ PageId: pageId }).toArray() as PageNode[]; const inflateRecords = (records) => { return records.map(rec => { const host = new HostRecord(rec.Value.Value); host.load(rec); return host; }); }; // If we're offline, just resolve the offline nodes if ( this.api.isOffline ) { const parsedRecords = existingNodes.map(x => x.inflateToRecord()); return res(inflateRecords(parsedRecords)); } this.api.get(`/page/${pageId}/nodes${version ? '?version=' + version : ''}`).subscribe({ next: async result => { // If we got resolved records, delete the local ones to replace them await this.db.pageNodes.where({ PageId: pageId }).delete(); for ( const rawRec of result.data ) { const newLocalNode = new PageNode( rawRec.UUID, rawRec.Type, JSON.stringify(rawRec.Value), rawRec.PageId, rawRec.CreatedAt, rawRec.UpdatedAt, rawRec.CreatedUserId, rawRec.UpdateUserId, ); await newLocalNode.save(); } res(inflateRecords(result.data)); }, error: rej, }); }); } public createPage(name: string): Promise { return new Promise(async (res, rej) => { const userId = this.session.get('user.id'); if ( !userId ) { throw new ResourceNotAvailableOfflineError(); } // If we're offline, create a stub page to be saved later if ( this.api.isOffline ) { const page = new Page( Page.getUUID(), name, userId, true, true, '0', [], String(new Date()), String(new Date()), true, userId, userId, [], false, false, 1 ); const firstNode = new PageNode( PageNode.getUUID(), 'paragraph', JSON.stringify({ Value: 'Double-click to edit...' }), page.UUID, String(new Date()), String(new Date()), userId, userId, true ); await firstNode.save(); page.NodeIds.push(firstNode.UUID); await page.save(); // Because we're offline, we need to manually create the menu item node const topLevelItem = await this.db.menuItems.where({ serverId: 0, name: 'My Info Tree', }).first() as MenuItem; if ( topLevelItem ) { const newItem = new MenuItem( page.Name, page.UUID, [], false, false, false, 'page', false, true ); await newItem.save(); topLevelItem.childIds.push(newItem.serverId); await topLevelItem.save(); } return res(page.getSaveRecord()); } // If we're online, the server will handle all of that mess... this.api.post('/page/create', { name }).subscribe({ next: async result => { const page = new Page( result.data.UUID, result.data.Name, result.data.OrgUserId, result.data.IsPublic, result.data.IsVisibleInMenu, result.data.ParentId, result.data.NodeIds, result.data.CreatedAt, result.data.UpdatedAt, true, result.data.CreatedUserId, result.data.UpdateUserId, result.data.ChildPageIds, result.data.noDelete, result.data.virtual ); await page.save(); return res(result.data); }, error: rej, }); }); } }