This commit is contained in:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
65
src/app/service/db/KeyValue.ts
Normal file
65
src/app/service/db/KeyValue.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
171
src/app/service/db/MenuItem.ts
Normal file
171
src/app/service/db/MenuItem.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/app/service/db/Migration.ts
Normal file
43
src/app/service/db/Migration.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
31
src/app/service/db/Model.ts
Normal file
31
src/app/service/db/Model.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
67
src/app/service/db/database.service.ts
Normal file
67
src/app/service/db/database.service.ts
Normal 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);
|
||||
// });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user