From 8de9db08a67e3a3ba108bb1d1ddcf3e4edfc242f Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 21 Oct 2020 21:12:04 -0500 Subject: [PATCH] Add offline caching for code editor contents --- .../editor/code/code.component.html | 5 +- .../editor/code/code.component.scss | 6 + .../components/editor/code/code.component.ts | 86 ++++----- src/app/service/api.service.ts | 166 ++++++++++++++++++ src/app/service/db/Codium.ts | 87 +++++++++ src/app/service/db/Model.ts | 5 + src/app/service/db/database.service.ts | 13 +- src/app/utility.ts | 8 + 8 files changed, 321 insertions(+), 55 deletions(-) create mode 100644 src/app/service/db/Codium.ts create mode 100644 src/app/utility.ts diff --git a/src/app/components/editor/code/code.component.html b/src/app/components/editor/code/code.component.html index 7db4cd6..d36d71e 100644 --- a/src/app/components/editor/code/code.component.html +++ b/src/app/components/editor/code/code.component.html @@ -1,4 +1,4 @@ -
+
Language @@ -16,3 +16,6 @@ >
+
+ Sorry, this code editor is not available offline yet. +
diff --git a/src/app/components/editor/code/code.component.scss b/src/app/components/editor/code/code.component.scss index ef5db85..19ad2d2 100644 --- a/src/app/components/editor/code/code.component.scss +++ b/src/app/components/editor/code/code.component.scss @@ -1,4 +1,10 @@ div.code-wrapper { border: 2px solid #8c8c8c; border-radius: 3px; + + &.not-offline { + text-align: center; + padding-top: 100px; + color: #595959; + } } diff --git a/src/app/components/editor/code/code.component.ts b/src/app/components/editor/code/code.component.ts index 4d559f5..9f7cb3a 100644 --- a/src/app/components/editor/code/code.component.ts +++ b/src/app/components/editor/code/code.component.ts @@ -1,6 +1,6 @@ import {Component, Input, OnInit} from '@angular/core'; import {v4} from 'uuid'; -import {ApiService} from '../../../service/api.service'; +import {ApiService, ResourceNotAvailableOfflineError} from '../../../service/api.service'; import {EditorNodeContract} from '../../nodes/EditorNode.contract'; import {EditorService} from '../../../service/editor.service'; @@ -14,6 +14,7 @@ export class CodeComponent extends EditorNodeContract implements OnInit { public dirty = false; protected dbRecord: any = {}; protected codeRefId!: string; + public notAvailableOffline = false; public editorOptions = { language: 'javascript', @@ -123,34 +124,36 @@ export class CodeComponent extends EditorNodeContract implements OnInit { } if ( !this.node.Value.Value && this.editorService.canEdit() ) { - this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/create`).subscribe({ - next: result => { - this.dbRecord = result.data; - this.node.Value.Mode = 'code'; - this.node.Value.Value = result.data.UUID; - this.node.value = result.data.UUID; - this.codeRefId = result.data.UUID; - this.editorOptions.readOnly = this.readonly; - this.onSelectChange(false); - this.hadLoad = true; - res(); - }, - error: rej, - }); + this.api.createCodium(this.page.UUID, this.node.UUID).then(data => { + this.dbRecord = data; + this.node.Value.Mode = 'code'; + this.node.Value.Value = data.UUID; + this.node.value = data.UUID; + this.codeRefId = data.UUID; + this.editorOptions.readOnly = this.readonly; + this.onSelectChange(false); + this.hadLoad = true; + this.notAvailableOffline = false; + res(); + }).catch(rej); } else { - this.api.get(`/code/${this.page.UUID}/${this.node.UUID}/get/${this.node.Value.Value}`).subscribe({ - next: result => { - this.dbRecord = result.data; - this.initialValue = this.dbRecord.code; - this.editorValue = this.dbRecord.code; - this.editorOptions.language = this.dbRecord.Language; - this.codeRefId = this.node.Value.Value; - this.editorOptions.readOnly = this.readonly; - this.onSelectChange(false); - this.hadLoad = true; - res(); - }, - error: rej, + this.api.getCodium(this.page.UUID, this.node.UUID, this.node.Value.Value).then(data => { + this.dbRecord = data; + this.initialValue = this.dbRecord.code; + this.editorValue = this.dbRecord.code; + this.editorOptions.language = this.dbRecord.Language; + this.codeRefId = this.node.Value.Value; + this.editorOptions.readOnly = this.readonly; + this.onSelectChange(false); + this.hadLoad = true; + this.notAvailableOffline = false; + res(); + }).catch(e => { + if ( e instanceof ResourceNotAvailableOfflineError ) { + this.notAvailableOffline = true; + } else { + rej(e); + } }); } }); @@ -165,29 +168,18 @@ export class CodeComponent extends EditorNodeContract implements OnInit { this.dbRecord.code = this.editorValue; this.dbRecord.Language = this.editorOptions.language; - this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/set/${this.node.Value.Value}`, this.dbRecord) - .subscribe({ - next: result => { - this.dbRecord = result.data; - this.editorOptions.language = this.dbRecord.Language; - this.editorValue = this.dbRecord.code; - this.dirty = false; - res(); - }, - error: rej, - }); + this.api.saveCodium(this.page.UUID, this.node.UUID, this.node.Value.Value, this.dbRecord).then(data => { + this.dbRecord = data; + this.editorOptions.language = this.dbRecord.Language; + this.editorValue = this.dbRecord.code; + this.dirty = false; + res(); + }).catch(rej); }); } public performDelete(): void | Promise { - return new Promise((res, rej) => { - this.api.post(`/code/${this.page.UUID}/${this.node.UUID}/delete/${this.node.Value.Value}`).subscribe({ - next: result => { - res(); - }, - error: rej, - }); - }); + return this.api.deleteCodium(this.page.UUID, this.node.UUID, this.node.Value.Value); } ngOnInit() { diff --git a/src/app/service/api.service.ts b/src/app/service/api.service.ts index 78f2ca6..8a9536a 100644 --- a/src/app/service/api.service.ts +++ b/src/app/service/api.service.ts @@ -6,6 +6,13 @@ import ApiResponse from '../structures/ApiResponse'; import {MenuItem} from './db/MenuItem'; import {DatabaseService} from './db/database.service'; import {ConnectionService} from 'ng-connection-service'; +import {Codium} from './db/Codium'; + +export class ResourceNotAvailableOfflineError extends Error { + constructor(msg = 'This resource is not yet available offline on this device.') { + super(msg); + } +} @Injectable({ providedIn: 'root' @@ -221,4 +228,163 @@ export class ApiService { res(); }); } + + public deleteCodium(PageId: string, NodeId: string, CodiumId: string): Promise { + return new Promise(async (res, rej) => { + const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); + const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; + + if ( this.isOffline ) { + if ( existingLocalCodium ) { + existingLocalCodium.deleted = true; + existingLocalCodium.needsServerUpdate = true; + await existingLocalCodium.save(); + return res(); + } else { + return rej(new ResourceNotAvailableOfflineError()); + } + } + + this.post(`/code/${PageId}/${NodeId}/delete/${CodiumId}`).subscribe({ + next: async result => { + if ( existingLocalCodium ) { + await this.db.codiums.delete(existingLocalCodium.id); + } + res(); + }, + error: rej, + }); + }); + } + + public saveCodium(PageId: string, NodeId: string, CodiumId: string, data: any): Promise { + return new Promise(async (res, rej) => { + const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); + const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; + + // If we're offline, update or create the local record + if ( this.isOffline ) { + if ( existingLocalCodium ) { + existingLocalCodium.fillFromRecord(data); + existingLocalCodium.needsServerUpdate = true; + + await existingLocalCodium.save(); + return res(existingLocalCodium.getSaveRecord()); + } else { + const newLocalCodium = new Codium( + data.Language, + NodeId, + PageId, + data.code, + data.UUID || Codium.getUUID(), + true, + ); + + await newLocalCodium.save(); + return res(newLocalCodium.getSaveRecord()); + } + } + + // If we're online, save the data and update our local records + this.post(`/code/${PageId}/${NodeId}/set/${CodiumId}`, data).subscribe({ + next: async result => { + if ( existingLocalCodium ) { + existingLocalCodium.fillFromRecord(result.data); + existingLocalCodium.needsServerUpdate = false; + + await existingLocalCodium.save(); + return res(result.data); + } else { + const newLocalCodium = new Codium( + result.data.Language, + result.data.NodeId, + result.data.PageId, + result.data.code, + result.data.UUID, + ); + + await newLocalCodium.save(); + return res(result.data); + } + }, + error: rej, + }); + }); + } + + public createCodium(PageId: string, NodeId: string): Promise { + return new Promise(async (res, rej) => { + // If offline, create a new local DB record + if ( this.isOffline ) { + const newLocalCodium = new Codium( + 'javascript', + NodeId, + PageId, + '', + Codium.getUUID(), + true + ); + + await newLocalCodium.save(); + return res(newLocalCodium.getSaveRecord()); + } + + // If online, create a new record on the server and sync it to the local db + this.post(`/code/${PageId}/${NodeId}/create`).subscribe({ + next: async result => { + const newLocalCodium = new Codium( + result.data.Language, + result.data.NodeId, + result.data.PageId, + result.data.code, + result.data.UUID, + ); + + await newLocalCodium.save(); + res(result.data); + }, + error: rej, + }); + }); + } + + public getCodium(PageId: string, NodeId: string, CodiumId: string): Promise { + return new Promise(async (res, rej) => { + const existingLocalCodiums = await this.db.codiums.where({ UUID: CodiumId }).toArray(); + const existingLocalCodium = existingLocalCodiums.length > 0 ? existingLocalCodiums[0] as Codium : undefined; + + // If offline, try to load it from the local DB + if ( this.isOffline ) { + if ( existingLocalCodium ) { + return res(existingLocalCodium.getSaveRecord()); + } else { + return rej(new ResourceNotAvailableOfflineError()); + } + } + + // If online, fetch the codium and store/update it locally + this.get(`/code/${PageId}/${NodeId}/get/${CodiumId}`).subscribe({ + next: async result => { + if ( existingLocalCodium ) { + existingLocalCodium.fillFromRecord(result.data); + + await existingLocalCodium.save(); + return res(result.data); + } else { + const newLocalCodium = new Codium( + result.data.Language, + result.data.NodeId, + result.data.PageId, + result.data.code, + result.data.UUID + ); + + await newLocalCodium.save(); + return res(result.data); + } + }, + error: rej, + }); + }); + } } diff --git a/src/app/service/db/Codium.ts b/src/app/service/db/Codium.ts new file mode 100644 index 0000000..f18cc0e --- /dev/null +++ b/src/app/service/db/Codium.ts @@ -0,0 +1,87 @@ +import {Model} from './Model'; + +export interface ICodium { + id?: number; + Language: string; + NodeId: string; + PageId: string; + code: string; + UUID: string; + needsServerUpdate?: boolean; + deleted?: boolean; +} + +export class Codium extends Model implements ICodium { + id?: number; + Language: string; + NodeId: string; + PageId: string; + code: string; + UUID: string; + needsServerUpdate?: boolean; + deleted?: boolean; + + public static getTableName() { + return 'codiums'; + } + + public static getSchema() { + return '++id, Language, NodeId, PageId, code, UUID, needsServerUpdate, deleted'; + } + + constructor( + Language: string, + NodeId: string, + PageId: string, + code: string, + UUID: string, + needsServerUpdate?: boolean, + deleted?: boolean, + id?: number + ) { + super(); + + this.Language = Language; + this.NodeId = NodeId; + this.PageId = PageId; + this.code = code; + this.UUID = UUID; + + if ( typeof needsServerUpdate !== 'undefined' ) { + this.needsServerUpdate = needsServerUpdate; + } + + if ( typeof deleted !== 'undefined' ) { + this.deleted = deleted; + } + + if ( id ) { + this.id = id; + } + } + + public fillFromRecord(record: any) { + this.Language = record.Language; + this.NodeId = record.NodeId; + this.PageId = record.PageId; + this.code = record.code; + this.UUID = record.UUID; + } + + public getSaveRecord(): any { + return { + ...(this.id ? { id: this.id } : {}), + Language: this.Language, + NodeId: this.NodeId, + PageId: this.PageId, + code: this.code, + UUID: this.UUID, + ...(typeof this.needsServerUpdate === 'undefined' ? {} : { needsServerUpdate: this.needsServerUpdate }), + ...(typeof this.deleted === 'undefined' ? {} : { deleted: this.deleted }), + }; + } + + public getDatabase(): Dexie.Table { + return this.staticClass().dbService.table('codiums') as Dexie.Table; + } +} diff --git a/src/app/service/db/Model.ts b/src/app/service/db/Model.ts index f7bbb55..2f1f5fe 100644 --- a/src/app/service/db/Model.ts +++ b/src/app/service/db/Model.ts @@ -1,5 +1,6 @@ import Dexie from 'dexie'; import {DatabaseService} from './database.service'; +import {uuid_v4} from '../../utility'; export abstract class Model { public static dbService?: DatabaseService; @@ -14,6 +15,10 @@ export abstract class Model { throw new TypeError('Child class must implement.'); } + public static getUUID(): string { + return uuid_v4(); + } + public abstract getDatabase(): Dexie.Table; public abstract getSaveRecord(): any; diff --git a/src/app/service/db/database.service.ts b/src/app/service/db/database.service.ts index 8e69950..c26f3ca 100644 --- a/src/app/service/db/database.service.ts +++ b/src/app/service/db/database.service.ts @@ -3,17 +3,19 @@ import Dexie from 'dexie'; import {IMigration, Migration} from './Migration'; import {IMenuItem, MenuItem} from './MenuItem'; import {KeyValue, IKeyValue} from './KeyValue'; +import {Codium, ICodium} from './Codium'; @Injectable({ providedIn: 'root' }) export class DatabaseService extends Dexie { - protected static registeredModels = [Migration, MenuItem, KeyValue]; + protected static registeredModels = [Migration, MenuItem, KeyValue, Codium]; protected initialized = false; migrations!: Dexie.Table; menuItems!: Dexie.Table; keyValues!: Dexie.Table; + codiums!: Dexie.Table; constructor( ) { @@ -46,7 +48,7 @@ export class DatabaseService extends Dexie { schema[ModelClass.getTableName()] = ModelClass.getSchema(); } - await this.version(3).stores(schema); + await this.version(5).stores(schema); await this.open(); this.migrations = this.table('migrations'); @@ -58,10 +60,7 @@ export class DatabaseService extends Dexie { this.keyValues = this.table('keyValues'); this.keyValues.mapToClass(KeyValue); - // await new Promise(res => { - // setTimeout(() => { - // res(); - // }, 1000); - // }); + this.codiums = this.table('codiums'); + this.codiums.mapToClass(Codium); } } diff --git a/src/app/utility.ts b/src/app/utility.ts new file mode 100644 index 0000000..1d3de37 --- /dev/null +++ b/src/app/utility.ts @@ -0,0 +1,8 @@ + +export function uuid_v4() { + // @ts-ignore + return ([1e7] + - 1e3 + - 4e3 + - 8e3 + - 1e11).replace(/[018]/g, c => + // tslint:disable-next-line:no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +}