From fe7e95587526ba15b8039d658da8693b50454b30 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 25 Oct 2020 18:42:27 -0500 Subject: [PATCH] Cache pages and page nodes for offline use --- .drone.yml | 15 -- src/app/pages/editor/editor.page.html | 7 +- src/app/pages/editor/editor.page.ts | 242 +------------------ src/app/service/db/Page.ts | 150 ++++++++++++ src/app/service/db/PageNode.ts | 113 +++++++++ src/app/service/db/database.service.ts | 19 +- src/app/service/editor.service.ts | 317 +++++++++++++++++++++++-- 7 files changed, 580 insertions(+), 283 deletions(-) create mode 100644 src/app/service/db/Page.ts create mode 100644 src/app/service/db/PageNode.ts diff --git a/.drone.yml b/.drone.yml index 41b825e..16330a6 100644 --- a/.drone.yml +++ b/.drone.yml @@ -128,18 +128,3 @@ steps: } when: status: failure - -# ================ DEPLOY STAGING ===================== - - name: promote staging build - image: plugins/downstream - settings: - server: https://ci.garrettmills.dev - token: - from_secret: drone_token - fork: false - last_successful: true - deploy: staging - repositories: - - Noded/frontend@master - when: - status: success \ No newline at end of file diff --git a/src/app/pages/editor/editor.page.html b/src/app/pages/editor/editor.page.html index 0bc96e8..2fda9ad 100644 --- a/src/app/pages/editor/editor.page.html +++ b/src/app/pages/editor/editor.page.html @@ -21,7 +21,12 @@ - + +
+ Sorry, this page is not available offline yet. +
+
+
{ - console.log('adding node', result.data); if ( !result.data ) { return; } @@ -104,238 +96,6 @@ export class EditorPage implements OnInit { this.editorService.addNode(result.data, position, positionNodeId); }); - // popover.onDidDismiss().then(arg => { - // const defValue = this.getDefaultValue(arg.data); - // const hostRec = new HostRecord(defValue); - // hostRec.type = arg.data; - // hostRec.PageId = this.pageRecord.UUID; - // - // if ( hostRec.type === 'ul' ) { - // hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); - // } - // - // this.hostRecords.push(hostRec); - // if ( hostRec.isNorm() ) { - // setTimeout(() => { - // this.editorHosts.toArray().reverse()[0].takeFocus(); - // }, 0); - // } else { - // this.onSaveClick(); - // } - // }); - await popover.present(); } - - // buttonIsVisible(index) { - // return this.visibleButtons.includes(index); - // } - // - // makeVisible(index) { - // if ( !this.buttonIsVisible(index) ) { - // this.visibleButtons.push(index); - // } - // } - // - // makeInvisible(index) { - // this.visibleButtons = this.visibleButtons.filter(x => x !== index); - // } - // - // ionViewDidEnter() { - // if ( this.pageId ) { - // this.pages.load(this.pageId).subscribe(pageRecord => { - // this.pageRecord = pageRecord; - // this.pages.get_nodes(pageRecord).subscribe((hosts: Array) => { - // this.hostRecords = hosts; - // if ( !pageRecord.isViewOnly() ) { - // this.onSaveClick(); - // } - // }); - // }); - // } else { - // this.router.navigate(['/home']); - // } - // } - // - // onHostRecordChange($event, i) { - // if ( !this.pageRecord.isViewOnly() ) { - // this.hostRecords[i] = $event; - // } - // } - // - // async onAddClick($event) { - // if ( this.pageRecord.isViewOnly() ) { - // return; - // } - // - // const popover = await this.popover.create({ - // component: NodePickerComponent, - // event: $event, - // }); - // - // popover.onDidDismiss().then(arg => { - // const defValue = this.getDefaultValue(arg.data); - // const hostRec = new HostRecord(defValue); - // hostRec.type = arg.data; - // hostRec.PageId = this.pageRecord.UUID; - // - // if ( hostRec.type === 'ul' ) { - // hostRec.value = JSON.stringify([{value: '', indentationLevel: 0}]); - // } - // - // this.hostRecords.push(hostRec); - // if ( hostRec.isNorm() ) { - // setTimeout(() => { - // this.editorHosts.toArray().reverse()[0].takeFocus(); - // }, 0); - // } else { - // this.onSaveClick(); - // } - // }); - // - // await popover.present(); - // } - // - // getDefaultValue(type: string) { - // if ( type === 'paragraph' ) { - // return ''; - // } else if ( type === 'header1' ) { - // return '# '; - // } else if ( type === 'header2' ) { - // return '## '; - // } else if ( type === 'header3' ) { - // return '### '; - // } else if ( type === 'header4' ) { - // return '#### '; - // } else if ( type === 'block_code' ) { - // return '```'; - // } else if ( type === 'click_link' ) { - // return 'https://'; - // } else if ( type === 'page_sep' ) { - // return '==='; - // } else { - // return ''; - // } - // } - // - // onNewHostRequested($event) { - // if ( this.pageRecord.isViewOnly() ) { - // return; - // } - // - // const insertAfter = this.getIndexFromRecord($event.record); - // const record = new HostRecord(''); - // const newHosts = [] - // this.hostRecords.forEach((rec, i) => { - // newHosts.push(rec); - // if ( i === insertAfter ) { - // newHosts.push(record); - // } - // }) - // - // this.hostRecords = newHosts; - // - // setTimeout(() => { - // this.editorHosts.toArray()[insertAfter + 1].takeFocus(); - // }, 0); - // } - // - // onDestroyHostRequested($event) { - // if ( this.pageRecord.isViewOnly() ) { - // return; - // } - // - // let removedIndex = 0; - // const newHostRecords = this.editorHosts.filter((host, i) => { - // if ( $event.record === host.record ) { - // removedIndex = i; - // } - // return host.record !== $event.record; - // }); - // - // const removedHost = this.editorHosts[removedIndex]; - // - // const hostRecords = newHostRecords.map(host => host.record); - // this.hostRecords = hostRecords; - // - // setTimeout(() => { - // let focusIndex; - // if ( removedIndex === 0 && this.editorHosts.toArray().length ) { - // focusIndex = 0; - // } else if ( removedIndex !== 0 ) { - // focusIndex = removedIndex - 1; - // } - // - // if ( focusIndex >= 0 ) { - // this.editorHosts.toArray()[focusIndex].takeFocus(false); - // } - // }, 0); - // } - // - // protected getIndexFromRecord(record) { - // let index; - // this.editorHosts.toArray().forEach((host, i) => { - // if ( host.record === record ) { - // index = i; - // } - // }); - // return index; - // } - // - // onSaveClick() { - // if ( this.pageRecord.isViewOnly() ) { - // return; - // } - // - // this.loader.create({message: 'Saving changes...'}).then(loader => { - // loader.present().then(() => { - // this.pageRecord.Name = this.titleBar.el.innerText.trim(); - // - // // First, save the page record itself - // this.pages.save(this.pageRecord).subscribe(pageRecord => { - // this.pageRecord = pageRecord; - // - // // Now, save the nodes - // this.pages.save_nodes(pageRecord, this.hostRecords).subscribe(result => { - // this.hostRecords = result; - // loader.dismiss(); - // }); - // }); - // }); - // }); - // } - // - // async onOptionsClick($event, i) { - // if ( this.pageRecord.isViewOnly() ) { - // return; - // } - // - // const popover = await this.popover.create({ - // component: HostOptionsComponent, - // event: $event, - // componentProps: { - // editor: this, - // index: i, - // event: $event, - // hostRecord: this.hostRecords[i], - // } - // }); - // - // popover.onDidDismiss().then((result) => { - // if ( result.data === 'delete_node' ) { - // $event.record = this.hostRecords[i]; - // this.onDestroyHostRequested($event); - // } - // }) - // - // await popover.present(); - // } - // - // onEditorKeydown($event) { - // if ( $event.key === 's' && $event.ctrlKey ) { - // $event.preventDefault(); - // this.onSaveClick(); - // } - // } - } diff --git a/src/app/service/db/Page.ts b/src/app/service/db/Page.ts new file mode 100644 index 0000000..5221b99 --- /dev/null +++ b/src/app/service/db/Page.ts @@ -0,0 +1,150 @@ +import {Model} from './Model'; + +export interface IPage { + id?: number; + UUID: string; + Name: string; + OrgUserId: string; + IsPublic: boolean; + IsVisibleInMenu: boolean; + ParentId: string; + NodeIds: string[]; + CreatedAt: string; + UpdatedAt: string; + Active: boolean; + CreatedUserId: string; + UpdateUserId: string; + ChildPageIds: string[]; + noDelete: boolean; + virtual: boolean; + needsServerUpdate?: boolean; + deleted?: boolean; +} + +export class Page extends Model implements IPage { + id?: number; + UUID: string; + Name: string; + OrgUserId: string; + IsPublic: boolean; + IsVisibleInMenu: boolean; + ParentId: string; + NodeIds: string[]; + CreatedAt: string; + UpdatedAt: string; + Active: boolean; + CreatedUserId: string; + UpdateUserId: string; + ChildPageIds: string[]; + noDelete: boolean; + virtual: boolean; + needsServerUpdate?: boolean; + deleted?: boolean; + + public static getTableName() { + return 'pages'; + } + + public static getSchema() { + // tslint:disable-next-line:max-line-length + return '++id, UUID, Name, OrgUserId, IsPublic, IsVisibleInMenu, ParentId, NodeIds, CreatedAt, UpdatedAt, Active, CreatedUserId, UpdateUserId, ChildPageIds, noDelete, virtual, needsServerUpdate, deleted'; + } + + constructor( + UUID: string, + Name: string, + OrgUserId: string, + IsPublic: boolean, + IsVisibleInMenu: boolean, + ParentId: string, + NodeIds: string[], + CreatedAt: string, + UpdatedAt: string, + Active: boolean, + CreatedUserId: string, + UpdateUserId: string, + ChildPageIds: string[], + noDelete: boolean, + virtual: boolean, + needsServerUpdate?: boolean, + deleted?: boolean, + id?: number + ) { + super(); + + this.UUID = UUID; + this.Name = Name; + this.OrgUserId = OrgUserId; + this.IsPublic = IsPublic; + this.IsVisibleInMenu = IsVisibleInMenu; + this.ParentId = ParentId; + this.NodeIds = NodeIds; + this.CreatedAt = CreatedAt; + this.UpdatedAt = UpdatedAt; + this.Active = Active; + this.CreatedUserId = CreatedUserId; + this.UpdateUserId = UpdateUserId; + this.ChildPageIds = ChildPageIds; + this.noDelete = noDelete; + this.virtual = virtual; + + if ( typeof needsServerUpdate !== 'undefined' ) { + this.needsServerUpdate = needsServerUpdate; + } + + if ( typeof deleted !== 'undefined' ) { + this.deleted = deleted; + } + + if ( id ) { + this.id = id; + } + } + + public fillFromRecord(record: any) { + console.log('page fill from record', record); + this.UUID = record.UUID; + this.Name = record.Name; + this.OrgUserId = record.OrgUserId; + this.IsPublic = record.IsPublic; + this.IsVisibleInMenu = record.IsVisibleInMenu; + this.ParentId = record.ParentId; + this.NodeIds = record.NodeIds; + console.log('setting node ids', this.NodeIds, record.NodeIds); + this.CreatedAt = String(record.CreatedAt); + this.UpdatedAt = String(record.UpdatedAt); + this.Active = record.Active; + this.CreatedUserId = record.CreatedUserId; + this.UpdateUserId = record.UpdateUserId; + this.ChildPageIds = record.ChildPageIds; + this.noDelete = record.noDelete; + this.virtual = record.virtual; + } + + public getSaveRecord(): any { + return { + ...(this.id ? { id: this.id } : {}), + UUID: this.UUID, + Name: this.Name, + OrgUserId: this.OrgUserId, + IsPublic: this.IsPublic, + IsVisibleInMenu: this.IsVisibleInMenu, + ParentId: this.ParentId, + NodeIds: this.NodeIds, + CreatedAt: new Date(this.CreatedAt), + UpdatedAt: new Date(this.UpdatedAt), + Active: this.Active, + CreatedUserId: this.CreatedUserId, + UpdateUserId: this.UpdateUserId, + ChildPageIds: this.ChildPageIds, + noDelete: this.noDelete, + virtual: this.virtual, + ...(typeof this.needsServerUpdate === 'undefined' ? {} : { needsServerUpdate: this.needsServerUpdate }), + ...(typeof this.deleted === 'undefined' ? {} : { deleted: this.deleted }), + }; + } + + public getDatabase(): Dexie.Table { + return this.staticClass().dbService.table('pages') as Dexie.Table; + } +} diff --git a/src/app/service/db/PageNode.ts b/src/app/service/db/PageNode.ts new file mode 100644 index 0000000..a543005 --- /dev/null +++ b/src/app/service/db/PageNode.ts @@ -0,0 +1,113 @@ +import {Model} from './Model'; + +export interface IPageNode { + id?: number; + UUID: string; + Type: string; + ValueJSON: string; + PageId: string; + CreatedAt: string; + UpdatedAt: string; + CreatedUserId: string; + UpdateUserId: string; + needsServerUpdate?: boolean; + deleted?: boolean; +} + +export class PageNode extends Model implements IPageNode { + id?: number; + UUID: string; + Type: string; + ValueJSON: string; + PageId: string; + CreatedAt: string; + UpdatedAt: string; + CreatedUserId: string; + UpdateUserId: string; + needsServerUpdate?: boolean; + deleted?: boolean; + + public static getTableName() { + return 'pageNodes'; + } + + public static getSchema() { + // tslint:disable-next-line:max-line-length + return '++id, UUID, Type, ValueJSON, PageId, CreatedAt, UpdatedAt, CreatedUserId, UpdateUserId, needsServerUpdate, deleted'; + } + + constructor( + UUID: string, + Type: string, + ValueJSON: string, + PageId: string, + CreatedAt: string, + UpdatedAt: string, + CreatedUserId: string, + UpdateUserId: string, + needsServerUpdate?: boolean, + deleted?: boolean, + id?: number + ) { + super(); + + this.UUID = UUID; + this.Type = Type; + this.ValueJSON = ValueJSON; + this.PageId = PageId; + this.CreatedAt = CreatedAt; + this.UpdatedAt = UpdatedAt; + this.CreatedUserId = CreatedUserId; + this.UpdateUserId = UpdateUserId; + + if ( typeof needsServerUpdate !== 'undefined' ) { + this.needsServerUpdate = needsServerUpdate; + } + + if ( typeof deleted !== 'undefined' ) { + this.deleted = deleted; + } + + if ( id ) { + this.id = id; + } + } + + public fillFromRecord(record: any) { + this.UUID = record.UUID; + this.Type = record.Type; + this.ValueJSON = JSON.stringify(record.Value); + this.PageId = record.PageId; + this.CreatedAt = String(record.CreatedAt); + this.UpdatedAt = String(record.UpdatedAt); + this.CreatedUserId = record.CreatedUserId; + this.UpdateUserId = record.UpdateUserId; + } + + public inflateToRecord() { + const record = this.getSaveRecord(); + record.Value = JSON.parse(record.ValueJSON); + delete record.ValueJSON; + return record; + } + + public getSaveRecord(): any { + return { + ...(this.id ? { id: this.id } : {}), + UUID: this.UUID, + Type: this.Type, + ValueJSON: this.ValueJSON, + PageId: this.PageId, + CreatedAt: new Date(this.CreatedAt), + UpdatedAt: new Date(this.UpdatedAt), + CreatedUserId: this.CreatedUserId, + UpdateUserId: this.UpdateUserId, + ...(typeof this.needsServerUpdate === 'undefined' ? {} : { needsServerUpdate: this.needsServerUpdate }), + ...(typeof this.deleted === 'undefined' ? {} : { deleted: this.deleted }), + }; + } + + public getDatabase(): Dexie.Table { + return this.staticClass().dbService.table('pageNodes') as Dexie.Table; + } +} diff --git a/src/app/service/db/database.service.ts b/src/app/service/db/database.service.ts index 21552c5..31b162d 100644 --- a/src/app/service/db/database.service.ts +++ b/src/app/service/db/database.service.ts @@ -8,12 +8,17 @@ import {Database, IDatabase} from './Database'; import {DatabaseColumn, IDatabaseColumn} from './DatabaseColumn'; import {DatabaseEntry, IDatabaseEntry} from './DatabaseEntry'; import {FileGroup, IFileGroup} from './FileGroup'; +import {Page, IPage} from './Page'; +import {PageNode, IPageNode} from './PageNode'; @Injectable({ providedIn: 'root' }) export class DatabaseService extends Dexie { - protected static registeredModels = [Migration, MenuItem, KeyValue, Codium, Database, DatabaseColumn, DatabaseEntry, FileGroup]; + protected static registeredModels = [ + Migration, MenuItem, KeyValue, Codium, Database, DatabaseColumn, DatabaseEntry, FileGroup, Page, PageNode + ]; + protected initialized = false; migrations!: Dexie.Table; @@ -24,6 +29,8 @@ export class DatabaseService extends Dexie { databaseColumns!: Dexie.Table; databaseEntries!: Dexie.Table; fileGroups!: Dexie.Table; + pages!: Dexie.Table; + pageNodes!: Dexie.Table; constructor( ) { @@ -56,7 +63,7 @@ export class DatabaseService extends Dexie { schema[ModelClass.getTableName()] = ModelClass.getSchema(); } - await this.version(11).stores(schema); + await this.version(14).stores(schema); await this.open(); this.migrations = this.table('migrations'); @@ -82,6 +89,12 @@ export class DatabaseService extends Dexie { this.fileGroups = this.table('fileGroups'); this.fileGroups.mapToClass(FileGroup); + + this.pages = this.table('pages'); + this.pages.mapToClass(Page); + + this.pageNodes = this.table('pageNodes'); + this.pageNodes.mapToClass(PageNode); } public async purge() { @@ -96,6 +109,8 @@ export class DatabaseService extends Dexie { this.databaseColumns.clear(), this.databaseEntries.clear(), this.fileGroups.clear(), + this.pages.clear(), + this.pageNodes.clear(), ]); } } diff --git a/src/app/service/editor.service.ts b/src/app/service/editor.service.ts index 6506466..76c4a51 100644 --- a/src/app/service/editor.service.ts +++ b/src/app/service/editor.service.ts @@ -1,10 +1,13 @@ import {Injectable} from '@angular/core'; -import {ApiService} from './api.service'; +import {ApiService, ResourceNotAvailableOfflineError} from './api.service'; import PageRecord 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'; export class NoPageLoadedError extends Error { constructor(msg = 'There is no page open for editing.') { @@ -35,6 +38,8 @@ export class EditorService { protected subs: Subscription[] = []; protected saving = false; protected saveTriggered = false; + public notAvailable = false; + protected privTriggerSave = debounce(() => { if ( this.saving ) { this.triggerSave(); @@ -84,6 +89,7 @@ export class EditorService { constructor( protected api: ApiService, protected nav: NavigationService, + protected db: DatabaseService, ) { } async startEditing(pageId: string) { @@ -91,9 +97,19 @@ export class EditorService { await this.stopEditing(); } - this.currentPage = await this.loadPage(pageId); - this.currentNodes = await this.loadNodes(pageId); - await this.ready$.next(true); + try { + this.currentPage = await this.loadPage(pageId); + this.currentNodes = await this.loadNodes(pageId); + 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() { @@ -159,12 +175,70 @@ export class EditorService { } async savePage(page: PageRecord): Promise { - await new Promise((res, rej) => { + 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 = true; + 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, + true, + ); + + await newLocalPage.save(); + } + + return res(); + } + this.api.post(`/page/${page.UUID}/save`, saveData).subscribe({ - next: result => { - console.log('save result', result); + 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, + false + ); + + await newLocalPage.save(); + } + res(); }, error: rej, @@ -173,19 +247,85 @@ export class EditorService { } async saveNodesAsPage(page: PageRecord, nodes: HostRecord[]): Promise { - return new Promise((res, rej) => { + 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 = true; + 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: result => { - res(result.data.map(rec => { + 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); - return host; - })); + returns.push(host); + } + + return res(returns); }, error: rej, }); @@ -193,12 +333,60 @@ export class EditorService { } async saveNodeToPage(page: PageRecord, node: HostRecord): Promise { - return new Promise((res, rej) => { + 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()); + } + + const newLocalNode = new PageNode( + nodeData.UUID || PageNode.getUUID(), + 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 = true; + 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: result => { + 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); + console.log('saving local page data', result.data.UUID); + await localPage.save(); + } + const host = new HostRecord(result.data.Value.Value); host.load(result.data); res(host); @@ -235,6 +423,16 @@ export class EditorService { 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(); @@ -270,6 +468,7 @@ export class EditorService { this.currentNodes.push(host); } + this.currentPage.NodeIds.push(host.UUID); this.dirtyOverride = true; this.triggerSave(); return host; @@ -325,10 +524,50 @@ export class EditorService { } async loadPage(pageId: string): Promise { - return new Promise((res, rej) => { + 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(existingLocalPage.getSaveRecord()); + } else { + return rej(new ResourceNotAvailableOfflineError()); + } + } + + // If we're online, fetch the page record and store it locally this.api.get(`/page/${pageId}`).subscribe({ - next: result => { - res(new PageRecord(result.data)); + next: async result => { + const page = new PageRecord(result.data); + + if ( existingLocalPage ) { + existingLocalPage.fillFromRecord(result.data); + existingLocalPage.needsServerUpdate = false; + 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, }); @@ -336,14 +575,44 @@ export class EditorService { } async loadNodes(pageId: string): Promise { - return new Promise((res, rej) => { + 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`).subscribe({ - next: result => { - res(result.data.map(rec => { - const host = new HostRecord(rec.Value.Value); - host.load(rec); - return host; - })); + 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, });