Start offline support
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2020-10-21 13:54:18 -05:00
parent a72fc72c83
commit f6168b6b7c
11 changed files with 694 additions and 85 deletions

View File

@@ -21,6 +21,7 @@ import {SessionService} from './service/session.service';
import {SearchComponent} from './components/search/Search.component';
import {NodeTypeIcons} from './structures/node-types';
import {NavigationService} from './service/navigation.service';
import {DatabaseService} from './service/db/database.service';
@Component({
selector: 'app-root',
@@ -79,6 +80,7 @@ export class AppComponent implements OnInit {
protected hasSearchOpen = false;
protected versionInterval?: any;
protected showedNewVersionAlert = false;
protected showedOfflineAlert = false;
protected initialized$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(
@@ -94,11 +96,14 @@ export class AppComponent implements OnInit {
protected loading: LoadingController,
protected navService: NavigationService,
protected toasts: ToastController,
protected db: DatabaseService,
) {
this.initializeApp();
}
_doInit() {
async _doInit() {
await this.db.createSchemata();
this.reloadMenuItems().subscribe(() => {
this.ready$.next(true);
setTimeout(() => {
@@ -402,17 +407,16 @@ export class AppComponent implements OnInit {
reloadMenuItems() {
return new Observable(sub => {
this.api.get('/menu/items').subscribe(result => {
this.nodes = result.data;
setTimeout(() => {
sub.next();
sub.complete();
}, 0);
this.api.getMenuItems().then(nodes => {
this.nodes = nodes;
sub.next();
sub.complete();
});
});
}
async initializeApp() {
console.log('app', this);
this.loader = await this.loading.create({
message: 'Starting up...',
cssClass: 'noded-loading-mask',
@@ -420,8 +424,24 @@ export class AppComponent implements OnInit {
});
await this.loader.present();
await this.platform.ready();
let toast: any;
this.api.offline$.subscribe(async isOffline => {
if ( isOffline && !this.showedOfflineAlert ) {
toast = await this.toasts.create({
cssClass: 'compat-toast-container',
message: 'Uh, oh! It looks like you\'re offline. Some features might not work as expected...',
});
this.showedOfflineAlert = true;
await toast.present();
} else if ( !isOffline && this.showedOfflineAlert ) {
await toast.dismiss();
this.showedOfflineAlert = false;
}
});
const stat: any = await this.session.stat();
if ( !stat.authenticated_user ) {

View File

@@ -15,6 +15,7 @@ import {AgGridModule} from 'ag-grid-angular';
import {MonacoEditorModule} from 'ngx-monaco-editor';
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { MarkdownModule } from 'ngx-markdown';
import {ConnectionServiceModule} from 'ng-connection-service';
/**
* This function is used internal to get a string instance of the `<base href="" />` value from `index.html`.
@@ -44,6 +45,7 @@ export function getBaseHref(platformLocation: PlatformLocation): string {
AgGridModule.withComponents([]),
MonacoEditorModule.forRoot(),
MarkdownModule.forRoot(),
ConnectionServiceModule,
],
providers: [
StatusBar,

View File

@@ -1,8 +1,11 @@
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment';
import {Injectable} from '@angular/core';
import {environment} from '../../environments/environment';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {BehaviorSubject, Observable} from 'rxjs';
import ApiResponse from '../structures/ApiResponse';
import {MenuItem} from './db/MenuItem';
import {DatabaseService} from './db/database.service';
import {ConnectionService} from 'ng-connection-service';
@Injectable({
providedIn: 'root'
@@ -11,10 +14,36 @@ export class ApiService {
protected baseEndpoint: string = environment.backendBase;
protected statUrl: string = environment.statUrl;
protected versionUrl: string = environment.versionUrl;
protected offline = false;
public readonly offline$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
get isOffline() {
return this.offline;
}
constructor(
protected http: HttpClient,
) { }
protected db: DatabaseService,
protected connection: ConnectionService,
) {
connection.monitor().subscribe(isConnected => {
if ( !isConnected ) {
this.makeOffline();
} else {
this.makeOnline(); // TODO add checks for server.
}
});
}
public makeOffline() {
this.offline = true;
this.offline$.next(true);
}
public makeOnline() {
this.offline = false;
this.offline$.next(false);
}
public get(endpoint, params = {}): Observable<ApiResponse> {
return this.request(endpoint, params, 'get');
@@ -29,14 +58,55 @@ export class ApiService {
}
public stat(): Observable<ApiResponse> {
return this._request(this.statUrl);
return new Observable<ApiResponse>(sub => {
(async () => {
const statKV = await this.db.getKeyValue('host_stat');
// If offline, look up the last stored stat for information
if ( this.isOffline ) {
if ( typeof statKV !== 'object' ) {
throw new Error('No locally stored host stat found.');
}
sub.next(new ApiResponse(statKV.data));
sub.complete();
}
// Otherwise, fetch the stat and cache it locally
this._request(this.statUrl).subscribe(apiResponse => {
statKV.data = {status: apiResponse.status, message: apiResponse.message, data: apiResponse.data};
statKV.save().then(() => {
sub.next(statKV.data);
sub.complete();
});
});
})();
});
}
public version(): Promise<string> {
return new Promise((res, rej) => {
return new Promise(async (res, rej) => {
const versionKV = await this.db.getKeyValue('app_version');
// If offline, look up the local app version.
if ( this.isOffline ) {
if ( versionKV ) {
return res(versionKV.data);
} else {
return rej(new Error('No local app version found.'));
}
}
// Otherwise, look up the app version and store it locally
this._request(this.versionUrl).subscribe({
next: result => {
res(result.data.text.trim());
next: async result => {
const version = result.data.text.trim();
versionKV.data = version;
await versionKV.save();
res(version);
},
error: rej,
});
@@ -76,4 +146,79 @@ export class ApiService {
return `${this.baseEndpoint.endsWith('/') ? this.baseEndpoint.slice(0, -1) : this.baseEndpoint}${endpoint}`;
}
public getMenuItems(): Promise<any[]> {
return new Promise(async (res, rej) => {
await this.db.createSchemata();
// If offline, fetch the menu from the database
if ( this.isOffline ) {
const items = await this.db.menuItems.toArray();
const nodes = MenuItem.inflateTree(items as MenuItem[]);
return res(nodes);
}
// Download the latest menu items
const tree: any[] = await new Promise(res2 => {
this.get('/menu/items').subscribe({
next: async result => {
const nodes = result.data as any[];
const items = MenuItem.deflateTree(nodes);
// Update the locally stored nodes
await this.db.menuItems.clear();
await Promise.all(items.map(item => item.save()));
res2(nodes);
},
error: rej,
});
});
res(tree);
});
}
public getSessionData(): Promise<any> {
return new Promise(async (res, rej) => {
const sessionKV = await this.db.getKeyValue('session_data');
// If offline, just return the locally cached session data
if ( this.isOffline ) {
if ( typeof sessionKV.data !== 'object' ) {
return rej(new Error('No locally cached session data found.'));
}
return res(sessionKV.data);
}
// Otherwise, fetch the session data from the server and cache it locally
this.get('/session').subscribe(async result => {
sessionKV.data = result.data;
await sessionKV.save();
res(sessionKV.data);
});
});
}
public saveSessionData(data: any): Promise<void> {
return new Promise(async (res, rej) => {
// Update the local session data
const sessionKV = await this.db.getKeyValue('session_data');
sessionKV.data = data;
await sessionKV.save();
// If we're not offline, then update the data on the server
if ( !this.isOffline ) {
await new Promise(res2 => {
this.post('/session', data || {}).subscribe({
next: res2,
error: rej,
});
});
}
res();
});
}
}

View File

@@ -0,0 +1,65 @@
import {Model} from './Model';
export interface IKeyValue {
id?: number;
key: string;
value: string;
json: boolean;
}
export class KeyValue extends Model<IKeyValue> implements IKeyValue {
id?: number;
key: string;
value: string;
json: boolean;
public static getTableName() {
return 'keyValues';
}
public static getSchema() {
return '++id, key, value, json';
}
constructor(key: string, value: string, json: boolean, id?: number) {
super();
this.key = key;
this.value = value;
this.json = json;
if ( id ) {
this.id = id;
}
}
get data(): any {
if ( this.json ) {
return JSON.parse(this.value);
}
return this.value;
}
set data(val: any) {
if ( typeof val === 'string' ) {
this.json = false;
this.value = val;
} else {
this.json = true;
this.value = JSON.stringify(val);
}
}
public getSaveRecord(): any {
return {
...(this.id ? { id: this.id } : {}),
key: this.key,
value: this.value,
json: this.json,
};
}
public getDatabase(): Dexie.Table<IKeyValue, number> {
return this.staticClass().dbService.table('keyValues') as Dexie.Table<IKeyValue, number>;
}
}

View File

@@ -0,0 +1,171 @@
import {Model} from './Model';
import Dexie from 'dexie';
export interface IMenuItem {
id?: number;
serverId: string;
name: string;
childIds?: string[];
noDelete?: boolean;
noChildren?: boolean;
virtual?: boolean;
type?: string;
shared?: boolean;
needsServerUpdate?: boolean;
}
export class MenuItem extends Model<IMenuItem> implements IMenuItem {
id?: number;
serverId: string;
name: string;
childIds?: string[];
noDelete?: boolean;
noChildren?: boolean;
virtual?: boolean;
type?: string;
shared?: boolean;
needsServerUpdate?: boolean;
public static getTableName() {
return 'menuItems';
}
public static getSchema() {
return '++id, serverId, name, childIds, noDelete, noChildren, virtual, type, shared, needsServerUpdate';
}
public static deflateTree(nodes: any[]): MenuItem[] {
let items = [];
for ( const node of nodes ) {
const childIds = node.children ? node.children.map(x => x.id) : [];
const item = new MenuItem(node.name, node.id, childIds, node.noDelete, node.noChildren, node.virtual, node.type, node.shared);
items.push(item);
if ( node.children ) {
items = items.concat(...this.deflateTree(node.children));
}
}
return items;
}
public static inflateTree(items: MenuItem[]) {
const serverIdXItems: { [key: string]: MenuItem[] } = {};
for ( const item of items ) {
if ( !serverIdXItems[item.serverId] ) {
serverIdXItems[item.serverId] = [];
}
serverIdXItems[item.serverId].push(item);
}
const inflateNode = (item, alreadyChildren = [], seen = []) => {
const node: any = item.getSaveRecord();
seen.push(item);
node.id = node.serverId;
node.children = [];
if ( item.childIds ) {
for ( const childId of item.childIds ) {
if ( serverIdXItems[childId] ) {
const children = serverIdXItems[childId].filter(x => {
if ( x.type !== 'page' && item.serverId !== x.serverId ) {
return false;
}
return x !== item && !alreadyChildren.includes(x) && !seen.includes(x);
});
node.children = node.children.concat(...children.map(x => inflateNode(x, children, seen)));
}
}
}
const pageChildren = [];
const otherChildren = [];
for ( const child of node.children ) {
if ( child.type === 'page' ) {
pageChildren.push(child);
} else {
otherChildren.push(child);
}
}
node.children = [...otherChildren, ...pageChildren];
return node;
};
const topLevelItems = items.filter(x => String(x.serverId) === '0');
return topLevelItems.map(x => inflateNode(x));
}
constructor(
name: string,
serverId: string,
childIds?: string[],
noDelete?: boolean,
noChildren?: boolean,
virtual?: boolean,
type?: string,
shared?: boolean,
needsServerUpdate?: boolean,
id?: number
) {
super();
this.name = name;
this.serverId = serverId;
if ( childIds ) {
this.childIds = childIds;
}
if ( typeof noDelete !== 'undefined' ) {
this.noDelete = noDelete;
}
if ( typeof noChildren !== 'undefined' ) {
this.noChildren = noChildren;
}
if ( typeof virtual !== 'undefined' ) {
this.virtual = virtual;
}
if ( type ) {
this.type = type;
}
if ( typeof shared !== 'undefined' ) {
this.shared = shared;
}
if ( typeof needsServerUpdate !== 'undefined' ) {
this.needsServerUpdate = needsServerUpdate;
}
if ( id ) {
this.id = id;
}
}
public getDatabase(): Dexie.Table<IMenuItem, number> {
return this.staticClass().dbService.table('menuItems') as Dexie.Table<IMenuItem, number>;
}
public getSaveRecord(): any {
return {
...(this.id ? { id: this.id } : {}),
serverId: this.serverId,
name: this.name,
...(typeof this.childIds !== 'undefined' ? { childIds: this.childIds } : {}),
...(typeof this.noDelete !== 'undefined' ? { noDelete: this.noDelete } : {}),
...(typeof this.noChildren !== 'undefined' ? { noChildren: this.noChildren } : {}),
...(typeof this.virtual !== 'undefined' ? { virtual: this.virtual } : {}),
...(typeof this.type !== 'undefined' ? { type: this.type } : {}),
...(typeof this.shared !== 'undefined' ? { shared: this.shared } : {}),
...(typeof this.needsServerUpdate !== 'undefined' ? { needsServerUpdate: this.needsServerUpdate } : {}),
};
}
}

View File

@@ -0,0 +1,43 @@
import {Model} from './Model';
export interface IMigration {
id?: number;
uuid: string;
applied: boolean;
}
export class Migration extends Model<IMigration> implements IMigration {
id?: number;
uuid: string;
applied: boolean;
public static getTableName() {
return 'migrations';
}
public static getSchema() {
return '++id, uuid, applied';
}
constructor(uuid: string, applied: boolean, id?: number) {
super();
this.uuid = uuid;
this.applied = applied;
if ( id ) {
this.id = id;
}
}
public getSaveRecord(): any {
return {
...(this.id ? { id: this.id } : {}),
uuid: this.uuid,
applied: this.applied,
};
}
public getDatabase(): Dexie.Table<IMigration, number> {
return this.staticClass().dbService.table('migrations') as Dexie.Table<IMigration, number>;
}
}

View File

@@ -0,0 +1,31 @@
import Dexie from 'dexie';
import {DatabaseService} from './database.service';
export abstract class Model<InterfaceType> {
public static dbService?: DatabaseService;
public id?: number;
public static getSchema(): string {
throw new TypeError('Child class must implement.');
}
public static getTableName(): string {
throw new TypeError('Child class must implement.');
}
public abstract getDatabase(): Dexie.Table<InterfaceType, number>;
public abstract getSaveRecord(): any;
public staticClass() {
return (this.constructor as typeof Model);
}
public exists() {
return !!this.id;
}
public async save() {
this.id = await this.getDatabase().put(this.getSaveRecord());
}
}

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import Dexie from 'dexie';
import {IMigration, Migration} from './Migration';
import {IMenuItem, MenuItem} from './MenuItem';
import {KeyValue, IKeyValue} from './KeyValue';
@Injectable({
providedIn: 'root'
})
export class DatabaseService extends Dexie {
protected static registeredModels = [Migration, MenuItem, KeyValue];
protected initialized = false;
migrations!: Dexie.Table<IMigration, number>;
menuItems!: Dexie.Table<IMenuItem, number>;
keyValues!: Dexie.Table<IKeyValue, number>;
constructor(
) {
super('NodedLocalDatabase');
}
public async getKeyValue(key: string): Promise<KeyValue> {
const matches = await this.keyValues.where({ key }).toArray();
if ( matches.length > 0 ) {
return matches[0] as KeyValue;
}
return new KeyValue(key, '', false);
}
public async createSchemata() {
if ( this.initialized ) {
return;
}
this.initialized = true;
console.log('db', this);
const staticClass = this.constructor as typeof DatabaseService;
const schema: any = {};
for ( const ModelClass of staticClass.registeredModels ) {
ModelClass.dbService = this;
schema[ModelClass.getTableName()] = ModelClass.getSchema();
}
await this.version(3).stores(schema);
await this.open();
this.migrations = this.table('migrations');
this.migrations.mapToClass(Migration);
this.menuItems = this.table('menuItems');
this.menuItems.mapToClass(MenuItem);
this.keyValues = this.table('keyValues');
this.keyValues.mapToClass(KeyValue);
// await new Promise(res => {
// setTimeout(() => {
// res();
// }, 1000);
// });
}
}

View File

@@ -50,28 +50,13 @@ export class SessionService {
}
async initialize() {
return new Promise((res, rej) => {
this.api.get('/session').subscribe(response => {
this.data = response.data;
res();
});
});
this.data = await this.api.getSessionData();
}
async save() {
this.saving = true;
return new Promise((res, rej) => {
this.api.post('/session', this.data || {}).subscribe({
next: result => {
res();
this.saving = false;
},
error: (e) => {
this.saving = false;
rej(e);
},
});
});
await this.api.saveSessionData(this.data);
this.saving = false;
}
buildAppUrl(...parts: string[]): string {