From 538593e780a8400dce7fc4df775adc370157f1a2 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 29 Oct 2023 16:11:24 +0100 Subject: [PATCH 01/15] add basic extension support #753 --- benchmark/BenchmarkRunner.ts | 6 +- src/backend/ProjectPath.ts | 4 +- src/backend/index.ts | 2 +- src/backend/model/ObjectManagers.ts | 48 ++++++++-- src/backend/model/database/IObjectManager.ts | 2 + .../model/extension/ExtensionDecorator.ts | 27 ++++++ src/backend/model/extension/ExtensionEvent.ts | 57 ++++++++++++ .../model/extension/ExtensionManager.ts | 89 +++++++++++++++++++ src/backend/model/extension/IExtension.ts | 46 ++++++++++ .../model/fileaccess/MetadataLoader.ts | 2 + src/backend/model/jobs/JobManager.ts | 10 ++- src/backend/server.ts | 13 ++- src/common/config/private/MessagingConfig.ts | 1 - src/common/config/private/PrivateConfig.ts | 17 ++++ test/backend/DBTestHelper.ts | 6 +- .../integration/routers/PublicRouter.ts | 2 +- .../integration/routers/SharingRouter.ts | 2 +- .../backend/integration/routers/UserRouter.ts | 2 +- .../unit/middlewares/admin/SettingsMWs.ts | 2 +- .../unit/model/sql/AlbumManager.spec.ts | 2 +- .../unit/model/sql/CoverManager.spec.ts | 2 +- .../unit/model/sql/SearchManager.spec.ts | 2 +- 22 files changed, 315 insertions(+), 29 deletions(-) create mode 100644 src/backend/model/extension/ExtensionDecorator.ts create mode 100644 src/backend/model/extension/ExtensionEvent.ts create mode 100644 src/backend/model/extension/ExtensionManager.ts create mode 100644 src/backend/model/extension/IExtension.ts diff --git a/benchmark/BenchmarkRunner.ts b/benchmark/BenchmarkRunner.ts index 30e4e941..6615a006 100644 --- a/benchmark/BenchmarkRunner.ts +++ b/benchmark/BenchmarkRunner.ts @@ -121,7 +121,7 @@ export class BenchmarkRunner { const bm = new Benchmark('List directory', req, async (): Promise => { await ObjectManagers.reset(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }, null, async (): Promise => { Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low; @@ -135,7 +135,7 @@ export class BenchmarkRunner { async bmListPersons(): Promise { const bm = new Benchmark('Listing Faces', Utils.clone(this.requestTemplate), async (): Promise => { await ObjectManagers.reset(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }, null, async (): Promise => { Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low; @@ -289,7 +289,7 @@ export class BenchmarkRunner { await fs.promises.rm(ProjectPath.DBFolder, {recursive: true, force: true}); Config.Database.type = DatabaseType.sqlite; Config.Jobs.scheduled = []; - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }; private async setupDB(): Promise { diff --git a/src/backend/ProjectPath.ts b/src/backend/ProjectPath.ts index 99d5e030..e18b23ab 100644 --- a/src/backend/ProjectPath.ts +++ b/src/backend/ProjectPath.ts @@ -2,13 +2,14 @@ import * as path from 'path'; import * as fs from 'fs'; import {Config} from '../common/config/private/Config'; -class ProjectPathClass { +export class ProjectPathClass { public Root: string; public ImageFolder: string; public TempFolder: string; public TranscodedFolder: string; public FacesFolder: string; public FrontendFolder: string; + public ExtensionFolder: string; public DBFolder: string; constructor() { @@ -35,6 +36,7 @@ class ProjectPathClass { this.TranscodedFolder = path.join(this.TempFolder, 'tc'); this.FacesFolder = path.join(this.TempFolder, 'f'); this.DBFolder = this.getAbsolutePath(Config.Database.dbFolder); + this.ExtensionFolder = path.join(this.Root, 'extension'); // create thumbnail folder if not exist if (!fs.existsSync(this.TempFolder)) { diff --git a/src/backend/index.ts b/src/backend/index.ts index 9a1e6701..43b1d65f 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -11,5 +11,5 @@ if ((process.argv || []).includes('--run-diagnostics')) { process.exit(0); }); } else { - new Server(); + Server.getInstance(); } diff --git a/src/backend/model/ObjectManagers.ts b/src/backend/model/ObjectManagers.ts index 8733bb39..b9023123 100644 --- a/src/backend/model/ObjectManagers.ts +++ b/src/backend/model/ObjectManagers.ts @@ -14,6 +14,7 @@ import {AlbumManager} from './database/AlbumManager'; import {PersonManager} from './database/PersonManager'; import {SharingManager} from './database/SharingManager'; import {IObjectManager} from './database/IObjectManager'; +import {ExtensionManager} from './extension/ExtensionManager'; const LOG_TAG = '[ObjectManagers]'; @@ -32,6 +33,7 @@ export class ObjectManagers { private jobManager: JobManager; private locationManager: LocationManager; private albumManager: AlbumManager; + private extensionManager: ExtensionManager; constructor() { this.managers = []; @@ -169,6 +171,18 @@ export class ObjectManagers { this.managers.push(this.jobManager as IObjectManager); } + get ExtensionManager(): ExtensionManager { + return this.extensionManager; + } + + set ExtensionManager(value: ExtensionManager) { + if (this.extensionManager) { + this.managers.splice(this.managers.indexOf(this.extensionManager as IObjectManager), 1); + } + this.extensionManager = value; + this.managers.push(this.extensionManager as IObjectManager); + } + public static getInstance(): ObjectManagers { if (this.instance === null) { this.instance = new ObjectManagers(); @@ -179,27 +193,33 @@ export class ObjectManagers { public static async reset(): Promise { Logger.silly(LOG_TAG, 'Object manager reset begin'); if ( - ObjectManagers.getInstance().IndexingManager && - ObjectManagers.getInstance().IndexingManager.IsSavingInProgress + ObjectManagers.getInstance().IndexingManager && + ObjectManagers.getInstance().IndexingManager.IsSavingInProgress ) { await ObjectManagers.getInstance().IndexingManager.SavingReady; } - if (ObjectManagers.getInstance().JobManager) { - ObjectManagers.getInstance().JobManager.stopSchedules(); + for (const manager of ObjectManagers.getInstance().managers) { + if (manager === ObjectManagers.getInstance().versionManager) { + continue; + } + if (manager.cleanUp) { + await manager.cleanUp(); + } } + await SQLConnection.close(); this.instance = null; - Logger.debug(LOG_TAG, 'Object manager reset'); + Logger.debug(LOG_TAG, 'Object manager reset done'); } - public static async InitSQLManagers(): Promise { + public static async InitManagers(): Promise { await ObjectManagers.reset(); await SQLConnection.init(); - this.initManagers(); + await this.initManagers(); Logger.debug(LOG_TAG, 'SQL DB inited'); } - private static initManagers(): void { + private static async initManagers(): Promise { ObjectManagers.getInstance().AlbumManager = new AlbumManager(); ObjectManagers.getInstance().GalleryManager = new GalleryManager(); ObjectManagers.getInstance().IndexingManager = new IndexingManager(); @@ -211,10 +231,20 @@ export class ObjectManagers { ObjectManagers.getInstance().VersionManager = new VersionManager(); ObjectManagers.getInstance().JobManager = new JobManager(); ObjectManagers.getInstance().LocationManager = new LocationManager(); + ObjectManagers.getInstance().ExtensionManager = new ExtensionManager(); + + for (const manager of ObjectManagers.getInstance().managers) { + if (manager === ObjectManagers.getInstance().versionManager) { + continue; + } + if (manager.init) { + await manager.init(); + } + } } public async onDataChange( - changedDir: ParentDirectoryDTO = null + changedDir: ParentDirectoryDTO = null ): Promise { await this.VersionManager.onNewDataVersion(); diff --git a/src/backend/model/database/IObjectManager.ts b/src/backend/model/database/IObjectManager.ts index 939c42e7..c03c6ba0 100644 --- a/src/backend/model/database/IObjectManager.ts +++ b/src/backend/model/database/IObjectManager.ts @@ -2,4 +2,6 @@ import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO'; export interface IObjectManager { onNewDataVersion?: (changedDir?: ParentDirectoryDTO) => Promise; + cleanUp?: () => Promise; + init?: () => Promise; } diff --git a/src/backend/model/extension/ExtensionDecorator.ts b/src/backend/model/extension/ExtensionDecorator.ts new file mode 100644 index 00000000..dbaae83c --- /dev/null +++ b/src/backend/model/extension/ExtensionDecorator.ts @@ -0,0 +1,27 @@ +import {IExtensionEvent, IExtensionEvents} from './IExtension'; +import {ObjectManagers} from '../ObjectManagers'; +import {ExtensionEvent} from './ExtensionEvent'; + +export const ExtensionDecorator = (fn: (ee: IExtensionEvents) => IExtensionEvent) => { + return ( + target: unknown, + propertyName: string, + descriptor: PropertyDescriptor + ) => { + const targetMethod = descriptor.value; + descriptor.value = async function(...args: I) { + const event = fn(ObjectManagers.getInstance().ExtensionManager.events) as ExtensionEvent; + const eventObj = {stopPropagation: false}; + const input = await event.triggerBefore({inputs: args}, eventObj); + + // skip the rest of the execution if the before handler asked for stop propagation + if (eventObj.stopPropagation) { + return input as O; + } + const out = await targetMethod.apply(this, args); + return await event.triggerAfter(out); + }; + + return descriptor; + }; +}; diff --git a/src/backend/model/extension/ExtensionEvent.ts b/src/backend/model/extension/ExtensionEvent.ts new file mode 100644 index 00000000..231b7b53 --- /dev/null +++ b/src/backend/model/extension/ExtensionEvent.ts @@ -0,0 +1,57 @@ +import {IExtensionAfterEventHandler, IExtensionBeforeEventHandler, IExtensionEvent} from './IExtension'; + +export class ExtensionEvent implements IExtensionEvent { + protected beforeHandlers: IExtensionBeforeEventHandler[] = []; + protected afterHandlers: IExtensionAfterEventHandler[] = []; + + public before(handler: IExtensionBeforeEventHandler): void { + if (typeof handler !== 'function') { + throw new Error('ExtensionEvent::before: Handler is not a function'); + } + this.beforeHandlers.push(handler); + } + + public after(handler: IExtensionAfterEventHandler): void { + if (typeof handler !== 'function') { + throw new Error('ExtensionEvent::after: Handler is not a function'); + } + this.afterHandlers.push(handler); + } + + public offAfter(handler: IExtensionAfterEventHandler): void { + this.afterHandlers = this.afterHandlers.filter((h) => h !== handler); + } + + public offBefore(handler: IExtensionBeforeEventHandler): void { + this.beforeHandlers = this.beforeHandlers.filter((h) => h !== handler); + } + + + public async triggerBefore(input: { inputs: I }, event: { stopPropagation: boolean }): Promise<{ inputs: I } | O> { + let pipe: { inputs: I } | O = input; + if (this.beforeHandlers && this.beforeHandlers.length > 0) { + const s = this.beforeHandlers.slice(0); + for (let i = 0; i < s.length; ++i) { + if (event.stopPropagation) { + break; + } + pipe = await s[i](pipe as { inputs: I }, event); + } + } + return pipe; + } + + public async triggerAfter(output: O): Promise { + if (this.afterHandlers && this.afterHandlers.length > 0) { + const s = this.afterHandlers.slice(0); + for (let i = 0; i < s.length; ++i) { + output = await s[i](output); + } + } + return output; + } + +} + + + diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts new file mode 100644 index 00000000..0c75e112 --- /dev/null +++ b/src/backend/model/extension/ExtensionManager.ts @@ -0,0 +1,89 @@ +import {ProjectPath} from '../../ProjectPath'; +import {Config} from '../../../common/config/private/Config'; +import * as fs from 'fs'; +import * as path from 'path'; +import {IObjectManager} from '../database/IObjectManager'; +import {Logger} from '../../Logger'; +import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; +import {ObjectManagers} from '../ObjectManagers'; +import {Server} from '../../server'; +import {ExtensionEvent} from './ExtensionEvent'; + +const LOG_TAG = '[ExtensionManager]'; + +export class ExtensionManager implements IObjectManager { + + events: IExtensionEvents = { + gallery: { + MetadataLoader: { + loadPhotoMetadata: new ExtensionEvent() + } + } + }; + + public async init() { + this.loadExtensionsList(); + await this.initExtensions(); + } + + public loadExtensionsList() { + if (!fs.existsSync(ProjectPath.ExtensionFolder)) { + return; + } + Config.Extensions.list = fs + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); + Config.Extensions.list.sort(); + } + + private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { + for (let i = 0; i < Config.Extensions.list.length; ++i) { + const extName = Config.Extensions.list[i]; + const extPath = path.join(ProjectPath.ExtensionFolder, extName); + const serverExt = path.join(extPath, 'server.js'); + if (!fs.existsSync(serverExt)) { + continue; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ext = require(serverExt); + await fn(ext, extName); + } + } + + private createExtensionObject(): IExtensionObject { + return { + app: { + objectManagers: ObjectManagers.getInstance(), + config: Config, + paths: ProjectPath, + expressApp: Server.getInstance().app + }, + events: null + }; + } + + private async initExtensions() { + await this.callServerFN(async (ext, extName) => { + if (typeof ext?.init === 'function') { + Logger.debug(LOG_TAG, 'Running Init on extension:' + extName); + await ext?.init(this.createExtensionObject()); + } + }); + } + + private async cleanUpExtensions() { + await this.callServerFN(async (ext, extName) => { + if (typeof ext?.cleanUp === 'function') { + Logger.debug(LOG_TAG, 'Running Init on extension:' + extName); + await ext?.cleanUp(); + } + }); + } + + + public async cleanUp() { + await this.cleanUpExtensions(); + } +} diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts new file mode 100644 index 00000000..badf542c --- /dev/null +++ b/src/backend/model/extension/IExtension.ts @@ -0,0 +1,46 @@ +import * as express from 'express'; +import {PrivateConfigClass} from '../../../common/config/private/Config'; +import {ObjectManagers} from '../ObjectManagers'; +import {ProjectPathClass} from '../../ProjectPath'; +import {ExtensionEvent} from './ExtensionEvent'; + + +export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; +export type IExtensionAfterEventHandler = (output: O) => Promise; + + +export interface IExtensionEvent { + before: (handler: IExtensionBeforeEventHandler) => void; + after: (handler: IExtensionAfterEventHandler) => void +} + +export interface IExtensionEvents { + gallery: { + // indexing: IExtensionEvent; + // scanningDirectory: IExtensionEvent; + MetadataLoader: { + loadPhotoMetadata: IExtensionEvent + } + + //listingDirectory: IExtensionEvent; + //searching: IExtensionEvent; + }; +} + +export interface IExtensionApp { + expressApp: express.Express; + config: PrivateConfigClass; + objectManagers: ObjectManagers; + paths: ProjectPathClass; +} + +export interface IExtensionObject { + app: IExtensionApp; + events: IExtensionEvents; +} + +export interface IServerExtension { + init(app: IExtensionObject): Promise; + + cleanUp?: () => Promise; +} diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 0e4915f7..b4e7e51d 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -12,6 +12,7 @@ import {IptcParser} from 'ts-node-iptc'; import {FFmpegFactory} from '../FFmpegFactory'; import {FfprobeData} from 'fluent-ffmpeg'; import {Utils} from '../../../common/Utils'; +import { ExtensionDecorator } from '../extension/ExtensionDecorator'; const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); @@ -128,6 +129,7 @@ export class MetadataLoader { fileSize: 0, }; + @ExtensionDecorator(e=>e.gallery.MetadataLoader.loadPhotoMetadata) public static loadPhotoMetadata(fullPath: string): Promise { return new Promise((resolve, reject) => { try { diff --git a/src/backend/model/jobs/JobManager.ts b/src/backend/model/jobs/JobManager.ts index 525da36d..dedc8db1 100644 --- a/src/backend/model/jobs/JobManager.ts +++ b/src/backend/model/jobs/JobManager.ts @@ -10,14 +10,15 @@ import {JobProgress} from './jobs/JobProgress'; import {JobProgressManager} from './JobProgressManager'; import {JobDTOUtils} from '../../../common/entities/job/JobDTO'; import {Utils} from '../../../common/Utils'; +import {IObjectManager} from '../database/IObjectManager'; const LOG_TAG = '[JobManager]'; -export class JobManager implements IJobListener { +export class JobManager implements IJobListener, IObjectManager { protected timers: { schedule: JobScheduleDTO; timer: NodeJS.Timeout }[] = []; protected progressManager: JobProgressManager = null; - constructor() { + async init(){ this.progressManager = new JobProgressManager(); this.runSchedules(); } @@ -124,7 +125,12 @@ export class JobManager implements IJobListener { return JobRepository.Instance.getAvailableJobs(); } + public async cleanUp() { + this.stopSchedules(); + } + public stopSchedules(): void { + Logger.silly(LOG_TAG, 'Stopping all schedules'); this.timers.forEach((t): void => clearTimeout(t.timer)); this.timers = []; } diff --git a/src/backend/server.ts b/src/backend/server.ts index dd32d7e6..5a2912a6 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -32,9 +32,18 @@ const LOG_TAG = '[server]'; export class Server { public onStarted = new Event(); - private app: express.Express; + public app: express.Express; private server: HttpServer; + static instance: Server; + + public static getInstance(): Server { + if (this.instance === null) { + this.instance = new Server(); + } + return this.instance; + } + constructor() { if (!(process.env.NODE_ENV === 'production')) { Logger.info( @@ -115,7 +124,7 @@ export class Server { Localizations.init(); this.app.use(locale(Config.Server.languages, 'en')); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); Router.route(this.app); diff --git a/src/common/config/private/MessagingConfig.ts b/src/common/config/private/MessagingConfig.ts index 1a01c5c3..9b9cb843 100644 --- a/src/common/config/private/MessagingConfig.ts +++ b/src/common/config/private/MessagingConfig.ts @@ -96,7 +96,6 @@ export class EmailMessagingConfig { smtp?: EmailSMTPMessagingConfig = new EmailSMTPMessagingConfig(); } - @SubConfigClass({softReadonly: true}) export class MessagingConfig { @ConfigProperty({ diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 896b617e..e88b65b6 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -1013,6 +1013,14 @@ export class ServerServiceConfig extends ClientServiceConfig { Log: ServerLogConfig = new ServerLogConfig(); } + + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsConfig { + @ConfigProperty({volatile: true}) + list: string[] = []; +} + @SubConfigClass({softReadonly: true}) export class ServerEnvironmentConfig { @ConfigProperty({volatile: true}) @@ -1133,6 +1141,15 @@ export class ServerConfig extends ClientConfig { }) Messaging: MessagingConfig = new MessagingConfig(); + + @ConfigProperty({ + tags: { + name: $localize`Extensions`, + uiIcon: 'ionCloudOutline' + } as TAGS, + }) + Extensions: ServerExtensionsConfig = new ServerExtensionsConfig(); + @ConfigProperty({ tags: { name: $localize`Jobs`, diff --git a/test/backend/DBTestHelper.ts b/test/backend/DBTestHelper.ts index 823c322b..04454782 100644 --- a/test/backend/DBTestHelper.ts +++ b/test/backend/DBTestHelper.ts @@ -111,7 +111,7 @@ export class DBTestHelper { } public static async persistTestDir(directory: DirectoryBaseDTO): Promise { - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); const connection = await SQLConnection.getConnection(); ObjectManagers.getInstance().IndexingManager.indexDirectory = () => Promise.resolve(null); @@ -197,7 +197,7 @@ export class DBTestHelper { const conn = await SQLConnection.getConnection(); await conn.query('CREATE DATABASE IF NOT EXISTS ' + conn.options.database); await SQLConnection.close(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); } private async clearUpMysql(): Promise { @@ -218,7 +218,7 @@ export class DBTestHelper { private async resetSQLite(): Promise { Logger.debug(LOG_TAG, 'resetting sqlite'); await this.clearUpSQLite(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); } private async clearUpSQLite(): Promise { diff --git a/test/backend/integration/routers/PublicRouter.ts b/test/backend/integration/routers/PublicRouter.ts index 3de22df9..91595b23 100644 --- a/test/backend/integration/routers/PublicRouter.ts +++ b/test/backend/integration/routers/PublicRouter.ts @@ -41,7 +41,7 @@ describe('PublicRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; diff --git a/test/backend/integration/routers/SharingRouter.ts b/test/backend/integration/routers/SharingRouter.ts index 679b8106..38560742 100644 --- a/test/backend/integration/routers/SharingRouter.ts +++ b/test/backend/integration/routers/SharingRouter.ts @@ -42,7 +42,7 @@ describe('SharingRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; diff --git a/test/backend/integration/routers/UserRouter.ts b/test/backend/integration/routers/UserRouter.ts index f70d3b5f..2ffcca1d 100644 --- a/test/backend/integration/routers/UserRouter.ts +++ b/test/backend/integration/routers/UserRouter.ts @@ -42,7 +42,7 @@ describe('UserRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; diff --git a/test/backend/unit/middlewares/admin/SettingsMWs.ts b/test/backend/unit/middlewares/admin/SettingsMWs.ts index ad20f8ad..b8a49a4f 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -21,7 +21,7 @@ describe('Settings middleware', () => { beforeEach(async () => { await ObjectManagers.reset(); await fs.promises.rm(tempDir, {recursive: true, force: true}); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }); it('should save empty enforced users settings', (done: (err?: any) => void) => { diff --git a/test/backend/unit/model/sql/AlbumManager.spec.ts b/test/backend/unit/model/sql/AlbumManager.spec.ts index d54c1591..30de6d1f 100644 --- a/test/backend/unit/model/sql/AlbumManager.spec.ts +++ b/test/backend/unit/model/sql/AlbumManager.spec.ts @@ -33,7 +33,7 @@ describe('AlbumManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await sqlHelper.setUpTestGallery(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }; diff --git a/test/backend/unit/model/sql/CoverManager.spec.ts b/test/backend/unit/model/sql/CoverManager.spec.ts index f6e5c2ac..85a856eb 100644 --- a/test/backend/unit/model/sql/CoverManager.spec.ts +++ b/test/backend/unit/model/sql/CoverManager.spec.ts @@ -122,7 +122,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await setUpTestGallery(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }; diff --git a/test/backend/unit/model/sql/SearchManager.spec.ts b/test/backend/unit/model/sql/SearchManager.spec.ts index ffc7091b..4590193c 100644 --- a/test/backend/unit/model/sql/SearchManager.spec.ts +++ b/test/backend/unit/model/sql/SearchManager.spec.ts @@ -146,7 +146,7 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await setUpTestGallery(); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.InitManagers(); }; From f7dba927b8bfe1dc3b6b116963329d33a1c7a17b Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Tue, 31 Oct 2023 20:38:08 +0100 Subject: [PATCH 02/15] Adds main events to extension #753 --- .gitignore | 1 + src/backend/Logger.ts | 42 +++- src/backend/ProjectPath.ts | 8 +- src/backend/model/database/AlbumManager.ts | 2 +- src/backend/model/database/CoverManager.ts | 194 ++++++++++-------- .../model/extension/ExtensionManager.ts | 39 +++- src/backend/model/extension/IExtension.ts | 56 +++-- src/backend/model/fileaccess/DiskManager.ts | 2 + .../model/fileaccess/MetadataLoader.ts | 2 + src/backend/model/fileaccess/PhotoWorker.ts | 2 + src/backend/server.ts | 10 +- .../unit/model/sql/CoverManager.spec.ts | 10 +- 12 files changed, 239 insertions(+), 129 deletions(-) diff --git a/.gitignore b/.gitignore index cde97b33..83d9a5ca 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ locale.source.xlf test.* /db/ /test/cypress/screenshots/ +/extensions/ diff --git a/src/backend/Logger.ts b/src/backend/Logger.ts index 0d2c19fd..7d860178 100644 --- a/src/backend/Logger.ts +++ b/src/backend/Logger.ts @@ -7,10 +7,42 @@ const forcedDebug = process.env['NODE_ENV'] === 'debug'; if (forcedDebug === true) { console.log( - 'NODE_ENV environmental variable is set to debug, forcing all logs to print' + 'NODE_ENV environmental variable is set to debug, forcing all logs to print' ); } +export type LoggerFunction = (...args: (string | number)[]) => void; + +export interface ILogger { + silly: LoggerFunction; + debug: LoggerFunction; + verbose: LoggerFunction; + info: LoggerFunction; + warn: LoggerFunction; + error: LoggerFunction; +} + +export const createLoggerWrapper = (TAG: string): ILogger => ({ + silly: (...args: (string | number)[]) => { + Logger.silly(TAG, ...args); + }, + debug: (...args: (string | number)[]) => { + Logger.debug(TAG, ...args); + }, + verbose: (...args: (string | number)[]) => { + Logger.verbose(TAG, ...args); + }, + info: (...args: (string | number)[]) => { + Logger.info(TAG, ...args); + }, + warn: (...args: (string | number)[]) => { + Logger.warn(TAG, ...args); + }, + error: (...args: (string | number)[]) => { + Logger.error(TAG, ...args); + } +}); + export class Logger { public static silly(...args: (string | number)[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.silly) { @@ -55,10 +87,10 @@ export class Logger { const date = new Date().toLocaleString(); let LOG_TAG = ''; if ( - args.length > 0 && - typeof args[0] === 'string' && - args[0].startsWith('[') && - args[0].endsWith(']') + args.length > 0 && + typeof args[0] === 'string' && + args[0].startsWith('[') && + args[0].endsWith(']') ) { LOG_TAG = args[0]; args.shift(); diff --git a/src/backend/ProjectPath.ts b/src/backend/ProjectPath.ts index e18b23ab..d6dc6603 100644 --- a/src/backend/ProjectPath.ts +++ b/src/backend/ProjectPath.ts @@ -16,15 +16,15 @@ export class ProjectPathClass { this.reset(); } - normalizeRelative(pathStr: string): string { + public normalizeRelative(pathStr: string): string { return path.join(pathStr, path.sep); } - getAbsolutePath(pathStr: string): string { + public getAbsolutePath(pathStr: string): string { return path.isAbsolute(pathStr) ? pathStr : path.join(this.Root, pathStr); } - getRelativePathToImages(pathStr: string): string { + public getRelativePathToImages(pathStr: string): string { return path.relative(this.ImageFolder, pathStr); } @@ -36,7 +36,7 @@ export class ProjectPathClass { this.TranscodedFolder = path.join(this.TempFolder, 'tc'); this.FacesFolder = path.join(this.TempFolder, 'f'); this.DBFolder = this.getAbsolutePath(Config.Database.dbFolder); - this.ExtensionFolder = path.join(this.Root, 'extension'); + this.ExtensionFolder = path.join(this.Root, 'extensions'); // create thumbnail folder if not exist if (!fs.existsSync(this.TempFolder)) { diff --git a/src/backend/model/database/AlbumManager.ts b/src/backend/model/database/AlbumManager.ts index 2665bfda..dcf013d0 100644 --- a/src/backend/model/database/AlbumManager.ts +++ b/src/backend/model/database/AlbumManager.ts @@ -19,7 +19,7 @@ export class AlbumManager implements IObjectManager { private static async updateAlbum(album: SavedSearchEntity): Promise { const connection = await SQLConnection.getConnection(); const cover = - await ObjectManagers.getInstance().CoverManager.getAlbumCover(album); + await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(album); const count = await ObjectManagers.getInstance().SearchManager.getCount((album as SavedSearchDTO).searchQuery); diff --git a/src/backend/model/database/CoverManager.ts b/src/backend/model/database/CoverManager.ts index e4bae610..351834e2 100644 --- a/src/backend/model/database/CoverManager.ts +++ b/src/backend/model/database/CoverManager.ts @@ -14,6 +14,7 @@ import {CoverPhotoDTO} from '../../../common/entities/PhotoDTO'; import {IObjectManager} from './IObjectManager'; import {Logger} from '../../Logger'; import {SearchManager} from './SearchManager'; +import {ExtensionDecorator} from '../extension/ExtensionDecorator'; const LOG_TAG = '[CoverManager]'; @@ -29,21 +30,22 @@ export class CoverManager implements IObjectManager { public async resetCovers(): Promise { const connection = await SQLConnection.getConnection(); await connection - .createQueryBuilder() - .update(DirectoryEntity) - .set({validCover: false}) - .execute(); + .createQueryBuilder() + .update(DirectoryEntity) + .set({validCover: false}) + .execute(); } - public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise { + @ExtensionDecorator(e => e.gallery.CoverManager.invalidateDirectoryCovers) + protected async invalidateDirectoryCovers(dir: ParentDirectoryDTO) { // Invalidating Album cover let fullPath = DiskManager.normalizeDirPath( - path.join(changedDir.path, changedDir.name) + path.join(dir.path, dir.name) ); const query = (await SQLConnection.getConnection()) - .createQueryBuilder() - .update(DirectoryEntity) - .set({validCover: false}); + .createQueryBuilder() + .update(DirectoryEntity) + .set({validCover: false}); let i = 0; const root = DiskManager.pathFromRelativeDirName('.'); @@ -53,62 +55,67 @@ export class CoverManager implements IObjectManager { fullPath = parentPath; ++i; query.orWhere( - new Brackets((q: WhereExpression) => { - const param: { [key: string]: string } = {}; - param['name' + i] = name; - param['path' + i] = parentPath; - q.where(`path = :path${i}`, param); - q.andWhere(`name = :name${i}`, param); - }) + new Brackets((q: WhereExpression) => { + const param: { [key: string]: string } = {}; + param['name' + i] = name; + param['path' + i] = parentPath; + q.where(`path = :path${i}`, param); + q.andWhere(`name = :name${i}`, param); + }) ); } ++i; query.orWhere( - new Brackets((q: WhereExpression) => { - const param: { [key: string]: string } = {}; - param['name' + i] = DiskManager.dirName('.'); - param['path' + i] = DiskManager.pathFromRelativeDirName('.'); - q.where(`path = :path${i}`, param); - q.andWhere(`name = :name${i}`, param); - }) + new Brackets((q: WhereExpression) => { + const param: { [key: string]: string } = {}; + param['name' + i] = DiskManager.dirName('.'); + param['path' + i] = DiskManager.pathFromRelativeDirName('.'); + q.where(`path = :path${i}`, param); + q.andWhere(`name = :name${i}`, param); + }) ); await query.execute(); } - public async getAlbumCover(album: { + public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise { + await this.invalidateDirectoryCovers(changedDir); + } + + @ExtensionDecorator(e => e.gallery.CoverManager.getCoverForAlbum) + public async getCoverForAlbum(album: { searchQuery: SearchQueryDTO; }): Promise { const albumQuery: Brackets = await - ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery); + ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery); const connection = await SQLConnection.getConnection(); const coverQuery = (): SelectQueryBuilder => { const query = connection - .getRepository(MediaEntity) - .createQueryBuilder('media') - .innerJoin('media.directory', 'directory') - .select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT]) - .where(albumQuery); + .getRepository(MediaEntity) + .createQueryBuilder('media') + .innerJoin('media.directory', 'directory') + .select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT]) + .where(albumQuery); SearchManager.setSorting(query, Config.AlbumCover.Sorting); return query; }; let coverMedia = null; if ( - Config.AlbumCover.SearchQuery && - !Utils.equalsFilter(Config.AlbumCover.SearchQuery, { - type: SearchQueryTypes.any_text, - text: '', - } as TextSearch) + Config.AlbumCover.SearchQuery && + !Utils.equalsFilter(Config.AlbumCover.SearchQuery, { + type: SearchQueryTypes.any_text, + text: '', + } as TextSearch) ) { try { const coverFilterQuery = await - ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery); + ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery); coverMedia = await coverQuery() - .andWhere(coverFilterQuery) - .limit(1) - .getOne(); + .andWhere(coverFilterQuery) + .limit(1) + .getOne(); } catch (e) { Logger.error(LOG_TAG, 'Cant get album cover using:', JSON.stringify(album.searchQuery), JSON.stringify(Config.AlbumCover.SearchQuery)); throw e; @@ -127,52 +134,53 @@ export class CoverManager implements IObjectManager { } public async getPartialDirsWithoutCovers(): Promise< - { id: number; name: string; path: string }[] + { id: number; name: string; path: string }[] > { const connection = await SQLConnection.getConnection(); return await connection - .getRepository(DirectoryEntity) - .createQueryBuilder('directory') - .where('directory.validCover = :validCover', {validCover: 0}) // 0 === false - .select(['name', 'id', 'path']) - .getRawMany(); + .getRepository(DirectoryEntity) + .createQueryBuilder('directory') + .where('directory.validCover = :validCover', {validCover: 0}) // 0 === false + .select(['name', 'id', 'path']) + .getRawMany(); } - public async setAndGetCoverForDirectory(dir: { + @ExtensionDecorator(e => e.gallery.CoverManager.getCoverForDirectory) + protected async getCoverForDirectory(dir: { id: number; name: string; path: string; - }): Promise { + }) { const connection = await SQLConnection.getConnection(); const coverQuery = (): SelectQueryBuilder => { const query = connection - .getRepository(MediaEntity) - .createQueryBuilder('media') - .innerJoin('media.directory', 'directory') - .select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT]) - .where( - new Brackets((q: WhereExpression) => { - q.where('media.directory = :dir', { - dir: dir.id, - }); - if (Config.Database.type === DatabaseType.mysql) { - q.orWhere('directory.path like :path || \'%\'', { - path: DiskManager.pathFromParent(dir), - }); - } else { - q.orWhere('directory.path GLOB :path', { - path: DiskManager.pathFromParent(dir) - // glob escaping. see https://github.com/bpatrik/pigallery2/issues/621 - .replaceAll('[', '[[]') + '*', - }); - } - }) - ); + .getRepository(MediaEntity) + .createQueryBuilder('media') + .innerJoin('media.directory', 'directory') + .select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT]) + .where( + new Brackets((q: WhereExpression) => { + q.where('media.directory = :dir', { + dir: dir.id, + }); + if (Config.Database.type === DatabaseType.mysql) { + q.orWhere('directory.path like :path || \'%\'', { + path: DiskManager.pathFromParent(dir), + }); + } else { + q.orWhere('directory.path GLOB :path', { + path: DiskManager.pathFromParent(dir) + // glob escaping. see https://github.com/bpatrik/pigallery2/issues/621 + .replaceAll('[', '[[]') + '*', + }); + } + }) + ); // Select from the directory if any otherwise from any subdirectories. // (There is no priority between subdirectories) query.orderBy( - `CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`, - 'ASC' + `CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`, + 'ASC' ); SearchManager.setSorting(query, Config.AlbumCover.Sorting); @@ -181,33 +189,43 @@ export class CoverManager implements IObjectManager { let coverMedia: CoverPhotoDTOWithID = null; if ( - Config.AlbumCover.SearchQuery && - !Utils.equalsFilter(Config.AlbumCover.SearchQuery, { - type: SearchQueryTypes.any_text, - text: '', - } as TextSearch) + Config.AlbumCover.SearchQuery && + !Utils.equalsFilter(Config.AlbumCover.SearchQuery, { + type: SearchQueryTypes.any_text, + text: '', + } as TextSearch) ) { coverMedia = await coverQuery() - .andWhere( - await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery) - ) - .limit(1) - .getOne(); + .andWhere( + await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery) + ) + .limit(1) + .getOne(); } if (!coverMedia) { coverMedia = await coverQuery().limit(1).getOne(); } + return coverMedia; + } + + public async setAndGetCoverForDirectory(dir: { + id: number; + name: string; + path: string; + }): Promise { + const connection = await SQLConnection.getConnection(); + const coverMedia = await this.getCoverForDirectory(dir); // set validCover bit to true even if there is no cover (to prevent future updates) await connection - .createQueryBuilder() - .update(DirectoryEntity) - .set({cover: coverMedia, validCover: true}) - .where('id = :dir', { - dir: dir.id, - }) - .execute(); + .createQueryBuilder() + .update(DirectoryEntity) + .set({cover: coverMedia, validCover: true}) + .where('id = :dir', { + dir: dir.id, + }) + .execute(); return coverMedia || null; } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 0c75e112..43e92628 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -3,7 +3,7 @@ import {Config} from '../../../common/config/private/Config'; import * as fs from 'fs'; import * as path from 'path'; import {IObjectManager} from '../database/IObjectManager'; -import {Logger} from '../../Logger'; +import {createLoggerWrapper, Logger} from '../../Logger'; import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; import {ObjectManagers} from '../ObjectManagers'; import {Server} from '../../server'; @@ -16,7 +16,19 @@ export class ExtensionManager implements IObjectManager { events: IExtensionEvents = { gallery: { MetadataLoader: { - loadPhotoMetadata: new ExtensionEvent() + loadPhotoMetadata: new ExtensionEvent(), + loadVideoMetadata: new ExtensionEvent() + }, + CoverManager: { + getCoverForDirectory: new ExtensionEvent(), + getCoverForAlbum: new ExtensionEvent(), + invalidateDirectoryCovers: new ExtensionEvent(), + }, + DiskManager: { + scanDirectory: new ExtensionEvent() + }, + ImageRenderer: { + render: new ExtensionEvent() } } }; @@ -27,15 +39,18 @@ export class ExtensionManager implements IObjectManager { } public loadExtensionsList() { + Logger.debug(LOG_TAG, 'Loading extension list from ' + ProjectPath.ExtensionFolder); if (!fs.existsSync(ProjectPath.ExtensionFolder)) { return; } + Config.Extensions.list = fs .readdirSync(ProjectPath.ExtensionFolder) .filter((f): boolean => fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() ); Config.Extensions.list.sort(); + Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list)); } private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { @@ -44,6 +59,7 @@ export class ExtensionManager implements IObjectManager { const extPath = path.join(ProjectPath.ExtensionFolder, extName); const serverExt = path.join(extPath, 'server.js'); if (!fs.existsSync(serverExt)) { + Logger.silly(LOG_TAG, `Skipping ${extName} server initiation. server.js does not exists`); continue; } // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -52,23 +68,24 @@ export class ExtensionManager implements IObjectManager { } } - private createExtensionObject(): IExtensionObject { + private createExtensionObject(name: string): IExtensionObject { return { - app: { + _app: { objectManagers: ObjectManagers.getInstance(), - config: Config, - paths: ProjectPath, - expressApp: Server.getInstance().app + expressApp: Server.getInstance().app, + config: Config }, - events: null + paths: ProjectPath, + Logger: createLoggerWrapper(`[Extension: ${name}]`), + events: this.events }; } private async initExtensions() { await this.callServerFN(async (ext, extName) => { if (typeof ext?.init === 'function') { - Logger.debug(LOG_TAG, 'Running Init on extension:' + extName); - await ext?.init(this.createExtensionObject()); + Logger.debug(LOG_TAG, 'Running init on extension: ' + extName); + await ext?.init(this.createExtensionObject(extName)); } }); } @@ -77,7 +94,7 @@ export class ExtensionManager implements IObjectManager { await this.callServerFN(async (ext, extName) => { if (typeof ext?.cleanUp === 'function') { Logger.debug(LOG_TAG, 'Running Init on extension:' + extName); - await ext?.cleanUp(); + await ext?.cleanUp(this.createExtensionObject(extName)); } }); } diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index badf542c..d8592682 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -2,7 +2,7 @@ import * as express from 'express'; import {PrivateConfigClass} from '../../../common/config/private/Config'; import {ObjectManagers} from '../ObjectManagers'; import {ProjectPathClass} from '../../ProjectPath'; -import {ExtensionEvent} from './ExtensionEvent'; +import {ILogger} from '../../Logger'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -11,36 +11,66 @@ export type IExtensionAfterEventHandler = (output: O) => Promise; export interface IExtensionEvent { before: (handler: IExtensionBeforeEventHandler) => void; - after: (handler: IExtensionAfterEventHandler) => void + after: (handler: IExtensionAfterEventHandler) => void; } +/** + * All main event callbacks in the app + */ export interface IExtensionEvents { gallery: { - // indexing: IExtensionEvent; - // scanningDirectory: IExtensionEvent; + /** + * Events for Directory and Album covers + */ + CoverManager: { + getCoverForAlbum: IExtensionEvent; + getCoverForDirectory: IExtensionEvent + /** + * Invalidates directory covers for a given directory and every parent + */ + invalidateDirectoryCovers: IExtensionEvent; + }, + ImageRenderer: { + /** + * Renders a thumbnail or photo + */ + render: IExtensionEvent + }, + /** + * Reads exif, iptc, etc.. metadata for photos/videos + */ MetadataLoader: { + loadVideoMetadata: IExtensionEvent, loadPhotoMetadata: IExtensionEvent + }, + /** + * Scans the storage for a given directory and returns the list of child directories, + * photos, videos and metafiles + */ + DiskManager: { + scanDirectory: IExtensionEvent } - - //listingDirectory: IExtensionEvent; - //searching: IExtensionEvent; }; } export interface IExtensionApp { expressApp: express.Express; - config: PrivateConfigClass; objectManagers: ObjectManagers; - paths: ProjectPathClass; + config: PrivateConfigClass; } export interface IExtensionObject { - app: IExtensionApp; + _app: IExtensionApp; + paths: ProjectPathClass; + Logger: ILogger; events: IExtensionEvents; } -export interface IServerExtension { - init(app: IExtensionObject): Promise; - cleanUp?: () => Promise; +/** + * Extension interface. All extension is expected to implement and export these methods + */ +export interface IServerExtension { + init(extension: IExtensionObject): Promise; + cleanUp?: (extension: IExtensionObject) => Promise; } diff --git a/src/backend/model/fileaccess/DiskManager.ts b/src/backend/model/fileaccess/DiskManager.ts index 2fca7d70..9226e25c 100644 --- a/src/backend/model/fileaccess/DiskManager.ts +++ b/src/backend/model/fileaccess/DiskManager.ts @@ -14,6 +14,7 @@ import {GPXProcessing} from './fileprocessing/GPXProcessing'; import {MDFileDTO} from '../../../common/entities/MDFileDTO'; import {MetadataLoader} from './MetadataLoader'; import {NotificationManager} from '../NotifocationManager'; +import {ExtensionDecorator} from '../extension/ExtensionDecorator'; const LOG_TAG = '[DiskManager]'; @@ -101,6 +102,7 @@ export class DiskManager { )) as ParentDirectoryDTO; } + @ExtensionDecorator(e => e.gallery.DiskManager.scanDirectory) public static async scanDirectory( relativeDirectoryName: string, settings: DirectoryScanSettings = {} diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index b4e7e51d..b4a309fd 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -18,6 +18,8 @@ const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); export class MetadataLoader { + + @ExtensionDecorator(e=>e.gallery.MetadataLoader.loadVideoMetadata) public static loadVideoMetadata(fullPath: string): Promise { return new Promise((resolve) => { const metadata: VideoMetadata = { diff --git a/src/backend/model/fileaccess/PhotoWorker.ts b/src/backend/model/fileaccess/PhotoWorker.ts index fe34ad5e..52b0887e 100644 --- a/src/backend/model/fileaccess/PhotoWorker.ts +++ b/src/backend/model/fileaccess/PhotoWorker.ts @@ -5,6 +5,7 @@ import {Logger} from '../../Logger'; import {FfmpegCommand, FfprobeData} from 'fluent-ffmpeg'; import {FFmpegFactory} from '../FFmpegFactory'; import * as path from 'path'; +import {ExtensionDecorator} from '../extension/ExtensionDecorator'; sharp.cache(false); @@ -129,6 +130,7 @@ export class VideoRendererFactory { export class ImageRendererFactory { + @ExtensionDecorator(e=>e.gallery.ImageRenderer.render) public static async render(input: MediaRendererInput | SvgRendererInput): Promise { let image: Sharp; diff --git a/src/backend/server.ts b/src/backend/server.ts index 5a2912a6..edad7b54 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -35,7 +35,7 @@ export class Server { public app: express.Express; private server: HttpServer; - static instance: Server; + static instance: Server = null; public static getInstance(): Server { if (this.instance === null) { @@ -70,7 +70,13 @@ export class Server { ).configPath + ':' ); - Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), null, '\t')); + Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), (k, v) => { + const MAX_LENGTH = 80; + if (typeof v === 'string' && v.length > MAX_LENGTH) { + v = v.slice(0, MAX_LENGTH - 3) + '...'; + } + return v; + }, 2)); this.app = express(); diff --git a/test/backend/unit/model/sql/CoverManager.spec.ts b/test/backend/unit/model/sql/CoverManager.spec.ts index 85a856eb..7c5cd9aa 100644 --- a/test/backend/unit/model/sql/CoverManager.spec.ts +++ b/test/backend/unit/model/sql/CoverManager.spec.ts @@ -212,21 +212,21 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => { it('should get cover for saved search', async () => { const pm = new CoverManager(); Config.AlbumCover.SearchQuery = null; - expect(Utils.clone(await pm.getAlbumCover({ + expect(Utils.clone(await pm.getCoverForAlbum({ searchQuery: { type: SearchQueryTypes.any_text, text: 'sw' } as TextSearch }))).to.deep.equalInAnyOrder(previewifyMedia(p4)); Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Boba'} as TextSearch; - expect(Utils.clone(await pm.getAlbumCover({ + expect(Utils.clone(await pm.getCoverForAlbum({ searchQuery: { type: SearchQueryTypes.any_text, text: 'sw' } as TextSearch }))).to.deep.equalInAnyOrder(previewifyMedia(p)); Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch; - expect(Utils.clone(await pm.getAlbumCover({ + expect(Utils.clone(await pm.getCoverForAlbum({ searchQuery: { type: SearchQueryTypes.any_text, text: 'sw' @@ -234,7 +234,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => { }))).to.deep.equalInAnyOrder(previewifyMedia(p2)); // Having a preview search query that does not return valid result Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'wont find it'} as TextSearch; - expect(Utils.clone(await pm.getAlbumCover({ + expect(Utils.clone(await pm.getCoverForAlbum({ searchQuery: { type: SearchQueryTypes.any_text, text: 'Derem' @@ -242,7 +242,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => { }))).to.deep.equalInAnyOrder(previewifyMedia(p2)); // having a saved search that does not have any image Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch; - expect(Utils.clone(await pm.getAlbumCover({ + expect(Utils.clone(await pm.getCoverForAlbum({ searchQuery: { type: SearchQueryTypes.any_text, text: 'wont find it' From 7a0f0c743cbbe3aa806fe2b282478dd6aa7d8888 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 4 Nov 2023 16:54:32 +0100 Subject: [PATCH 03/15] Add RestApi extendability #753 --- .../model/extension/ExpressRouterWrapper.ts | 72 +++++++++++++++++++ .../model/extension/ExtensionManager.ts | 38 +++++++--- src/backend/model/extension/IExtension.ts | 34 +++++++++ 3 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 src/backend/model/extension/ExpressRouterWrapper.ts diff --git a/src/backend/model/extension/ExpressRouterWrapper.ts b/src/backend/model/extension/ExpressRouterWrapper.ts new file mode 100644 index 00000000..12dccf86 --- /dev/null +++ b/src/backend/model/extension/ExpressRouterWrapper.ts @@ -0,0 +1,72 @@ +import * as express from 'express'; +import {NextFunction, Request, Response} from 'express'; +import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; +import {AuthenticationMWs} from '../../middlewares/user/AuthenticationMWs'; +import {RenderingMWs} from '../../middlewares/RenderingMWs'; +import {ParamsDictionary} from 'express-serve-static-core'; +import {IExtensionRESTApi, IExtensionRESTRoute} from './IExtension'; +import {Logger} from '../../Logger'; +import {ExtensionManager} from './ExtensionManager'; +import {Utils} from '../../../common/Utils'; + + +export class ExpressRouterWrapper implements IExtensionRESTApi { + + constructor(private readonly router: express.Router, private readonly name: string) { + } + + get use() { + return new ExpressRouteWrapper(this.router, this.name, 'use'); + } + + get get() { + return new ExpressRouteWrapper(this.router, this.name, 'get'); + } + + get put() { + return new ExpressRouteWrapper(this.router, this.name, 'put'); + } + + get post() { + return new ExpressRouteWrapper(this.router, this.name, 'post'); + } + + get delete() { + return new ExpressRouteWrapper(this.router, this.name, 'delete'); + } + +} + +export class ExpressRouteWrapper implements IExtensionRESTRoute { + + constructor(private readonly router: express.Router, + private readonly name: string, + private readonly func: 'get' | 'use' | 'put' | 'post' | 'delete') { + } + + private getAuthMWs(minRole: UserRoles) { + return minRole ? [AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(minRole)] : []; + } + + public jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown) { + const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p))); + this.router[this.func](fullPaths, + ...(this.getAuthMWs(minRole).concat([ + async (req: Request, res: Response, next: NextFunction) => { + req.resultPipe = await cb(req.params, req.body, req.session['user']); + next(); + }, + RenderingMWs.renderResult + ]))); + Logger.silly(`[ExtensionRest:${this.name}]`, `Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + } + + public rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise) { + const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p))); + this.router[this.func](fullPaths, + ...this.getAuthMWs(minRole), + mw); + Logger.silly(`[ExtensionRest:${this.name}]`, `Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + } +} diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 43e92628..995eb933 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -8,11 +8,15 @@ import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension import {ObjectManagers} from '../ObjectManagers'; import {Server} from '../../server'; import {ExtensionEvent} from './ExtensionEvent'; +import {ExpressRouterWrapper} from './ExpressRouterWrapper'; +import * as express from 'express'; const LOG_TAG = '[ExtensionManager]'; export class ExtensionManager implements IObjectManager { + public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension'; + events: IExtensionEvents = { gallery: { MetadataLoader: { @@ -32,8 +36,13 @@ export class ExtensionManager implements IObjectManager { } } }; + extObjects: { [key: string]: IExtensionObject } = {}; + router: express.Router; public async init() { + this.extObjects = {}; + this.router = express.Router(); + Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, this.router); this.loadExtensionsList(); await this.initExtensions(); } @@ -69,16 +78,23 @@ export class ExtensionManager implements IObjectManager { } private createExtensionObject(name: string): IExtensionObject { - return { - _app: { - objectManagers: ObjectManagers.getInstance(), - expressApp: Server.getInstance().app, - config: Config - }, - paths: ProjectPath, - Logger: createLoggerWrapper(`[Extension: ${name}]`), - events: this.events - }; + if (!this.extObjects[name]) { + const rw = new ExpressRouterWrapper(this.router, name); + this.extObjects[name] = { + _app: { + get objectManagers() { + return ObjectManagers.getInstance(); + }, + expressApp: Server.getInstance().app, + config: Config + }, + paths: ProjectPath, + Logger: createLoggerWrapper(`[Extension: ${name}]`), + events: this.events, + RESTApi: rw + }; + } + return this.extObjects[name]; } private async initExtensions() { @@ -102,5 +118,7 @@ export class ExtensionManager implements IObjectManager { public async cleanUp() { await this.cleanUpExtensions(); + Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, express.Router()); + this.extObjects = {}; } } diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index d8592682..6a366ff7 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -1,8 +1,11 @@ import * as express from 'express'; +import {NextFunction, Request, Response} from 'express'; import {PrivateConfigClass} from '../../../common/config/private/Config'; import {ObjectManagers} from '../ObjectManagers'; import {ProjectPathClass} from '../../ProjectPath'; import {ILogger} from '../../Logger'; +import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; +import {ParamsDictionary} from 'express-serve-static-core'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -59,11 +62,41 @@ export interface IExtensionApp { config: PrivateConfigClass; } +export interface IExtensionRESTRoute { + jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): void; + + rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): void; +} + +export interface IExtensionRESTApi { + use: IExtensionRESTRoute; + get: IExtensionRESTRoute; + post: IExtensionRESTRoute; + put: IExtensionRESTRoute; + delete: IExtensionRESTRoute; +} + export interface IExtensionObject { + /** + * Inner functionality of the app. Use this wit caution + */ _app: IExtensionApp; + /** + * Paths to the main components of the app. + */ paths: ProjectPathClass; + /** + * Logger of the app + */ Logger: ILogger; + /** + * Main app events. Use this change indexing, cover or serving gallery + */ events: IExtensionEvents; + /** + * Use this to define REST calls related to the extension + */ + RESTApi: IExtensionRESTApi; } @@ -72,5 +105,6 @@ export interface IExtensionObject { */ export interface IServerExtension { init(extension: IExtensionObject): Promise; + cleanUp?: (extension: IExtensionObject) => Promise; } From 4b215c1e5723338f83f82ed18bfe77e65b7b1202 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Wed, 8 Nov 2023 16:08:13 +0100 Subject: [PATCH 04/15] Add SLQ entities to extensions #753 --- src/backend/model/database/SQLConnection.ts | 117 ++++++++++++------ .../model/extension/ExpressRouterWrapper.ts | 25 ++-- src/backend/model/extension/ExtensionApp.ts | 18 +++ src/backend/model/extension/ExtensionDB.ts | 26 ++++ .../model/extension/ExtensionManager.ts | 71 ++++++----- src/backend/model/extension/IExtension.ts | 47 ++++++- src/common/config/private/PrivateConfig.ts | 9 ++ 7 files changed, 235 insertions(+), 78 deletions(-) create mode 100644 src/backend/model/extension/ExtensionApp.ts create mode 100644 src/backend/model/extension/ExtensionDB.ts diff --git a/src/backend/model/database/SQLConnection.ts b/src/backend/model/database/SQLConnection.ts index 80a26d4b..07eb2ec9 100644 --- a/src/backend/model/database/SQLConnection.ts +++ b/src/backend/model/database/SQLConnection.ts @@ -29,6 +29,38 @@ const LOG_TAG = '[SQLConnection]'; type Writeable = { -readonly [P in keyof T]: T[P] }; export class SQLConnection { + // eslint-disable-next-line @typescript-eslint/ban-types + public static getEntries(): Function[] { + return this.entries; + } + + // eslint-disable-next-line @typescript-eslint/ban-types + public static async addEntries(tables: Function[]) { + if (!tables?.length) { + return; + } + await this.close(); + this.entries = Utils.getUnique(this.entries.concat(tables)); + await (await this.getConnection()).synchronize(); + } + + // eslint-disable-next-line @typescript-eslint/ban-types + private static entries: Function[] = [ + UserEntity, + FileEntity, + MDFileEntity, + PersonJunctionTable, + PersonEntry, + MediaEntity, + PhotoEntity, + VideoEntity, + DirectoryEntity, + SharingEntity, + AlbumBaseEntity, + SavedSearchEntity, + VersionEntity, + ]; + private static connection: Connection = null; @@ -37,10 +69,10 @@ export class SQLConnection { const options = this.getDriver(Config.Database); Logger.debug( - LOG_TAG, - 'Creating connection: ' + DatabaseType[Config.Database.type], - ', with driver:', - options.type + LOG_TAG, + 'Creating connection: ' + DatabaseType[Config.Database.type], + ', with driver:', + options.type ); this.connection = await this.createConnection(options); await SQLConnection.schemeSync(this.connection); @@ -49,7 +81,7 @@ export class SQLConnection { } public static async tryConnection( - config: ServerDataBaseConfig + config: ServerDataBaseConfig ): Promise { try { await getConnection('test').close(); @@ -73,8 +105,8 @@ export class SQLConnection { // Adding enforced users to the db const userRepository = connection.getRepository(UserEntity); if ( - Array.isArray(Config.Users.enforcedUsers) && - Config.Users.enforcedUsers.length > 0 + Array.isArray(Config.Users.enforcedUsers) && + Config.Users.enforcedUsers.length > 0 ) { for (let i = 0; i < Config.Users.enforcedUsers.length; ++i) { const uc = Config.Users.enforcedUsers[i]; @@ -106,12 +138,12 @@ export class SQLConnection { role: UserRoles.Admin, }); if ( - defAdmin && - PasswordHelper.comparePassword('admin', defAdmin.password) + defAdmin && + PasswordHelper.comparePassword('admin', defAdmin.password) ) { NotificationManager.error( - 'Using default admin user!', - 'You are using the default admin/admin user/password, please change or remove it.' + 'Using default admin user!', + 'You are using the default admin/admin user/password, please change or remove it.' ); } } @@ -128,12 +160,39 @@ export class SQLConnection { } } + private static FIXED_SQL_TABLE = [ + 'sqlite_sequence' + ]; + + /** + * Clears up the DB from unused tables. use it when the entities list are up-to-date (extensions won't add any new) + */ + public static async removeUnusedTables() { + const conn = await this.getConnection(); + const validTableNames = this.entries.map(e => conn.getRepository(e).metadata.tableName).concat(this.FIXED_SQL_TABLE); + let currentTables: string[]; + + if (Config.Database.type === DatabaseType.sqlite) { + currentTables = (await conn.query('SELECT name FROM sqlite_master WHERE type=\'table\'')) + .map((r: { name: string }) => r.name); + } else { + currentTables = (await conn.query(`SELECT table_name FROM information_schema.tables ` + + `WHERE table_schema = '${Config.Database.mysql.database}'`)) + .map((r: { table_name: string }) => r.table_name); + } + + const tableToDrop = currentTables.filter(ct => !validTableNames.includes(ct)); + for (let i = 0; i < tableToDrop.length; ++i) { + await conn.query('DROP TABLE ' + tableToDrop[i]); + } + } + public static getSQLiteDB(config: ServerDataBaseConfig): string { return path.join(ProjectPath.getAbsolutePath(config.dbFolder), 'sqlite.db'); } private static async createConnection( - options: DataSourceOptions + options: DataSourceOptions ): Promise { if (options.type === 'sqlite' || options.type === 'better-sqlite3') { return await createConnection(options); @@ -149,7 +208,7 @@ export class SQLConnection { delete tmpOption.database; const tmpConn = await createConnection(tmpOption); await tmpConn.query( - 'CREATE DATABASE IF NOT EXISTS ' + options.database + 'CREATE DATABASE IF NOT EXISTS ' + options.database ); await tmpConn.close(); return await createConnection(options); @@ -177,9 +236,9 @@ export class SQLConnection { let users: UserEntity[] = []; try { users = await connection - .getRepository(UserEntity) - .createQueryBuilder('user') - .getMany(); + .getRepository(UserEntity) + .createQueryBuilder('user') + .getMany(); // eslint-disable-next-line no-empty } catch (ex) { } @@ -193,9 +252,9 @@ export class SQLConnection { await connection.synchronize(); await connection.getRepository(VersionEntity).save(version); Logger.warn( - LOG_TAG, - 'Could not move users to the new db scheme, deleting them. Details:' + - e.toString() + LOG_TAG, + 'Could not move users to the new db scheme, deleting them. Details:' + + e.toString() ); } } @@ -217,26 +276,12 @@ export class SQLConnection { driver = { type: 'better-sqlite3', database: path.join( - ProjectPath.getAbsolutePath(config.dbFolder), - config.sqlite.DBFileName + ProjectPath.getAbsolutePath(config.dbFolder), + config.sqlite.DBFileName ), }; } - driver.entities = [ - UserEntity, - FileEntity, - MDFileEntity, - PersonJunctionTable, - PersonEntry, - MediaEntity, - PhotoEntity, - VideoEntity, - DirectoryEntity, - SharingEntity, - AlbumBaseEntity, - SavedSearchEntity, - VersionEntity, - ]; + driver.entities = this.entries; driver.synchronize = false; if (Config.Server.Log.sqlLevel !== SQLLogLevel.none) { driver.logging = SQLLogLevel[Config.Server.Log.sqlLevel] as LoggerOptions; diff --git a/src/backend/model/extension/ExpressRouterWrapper.ts b/src/backend/model/extension/ExpressRouterWrapper.ts index 12dccf86..6f033126 100644 --- a/src/backend/model/extension/ExpressRouterWrapper.ts +++ b/src/backend/model/extension/ExpressRouterWrapper.ts @@ -5,34 +5,36 @@ import {AuthenticationMWs} from '../../middlewares/user/AuthenticationMWs'; import {RenderingMWs} from '../../middlewares/RenderingMWs'; import {ParamsDictionary} from 'express-serve-static-core'; import {IExtensionRESTApi, IExtensionRESTRoute} from './IExtension'; -import {Logger} from '../../Logger'; +import {ILogger} from '../../Logger'; import {ExtensionManager} from './ExtensionManager'; import {Utils} from '../../../common/Utils'; export class ExpressRouterWrapper implements IExtensionRESTApi { - constructor(private readonly router: express.Router, private readonly name: string) { + constructor(private readonly router: express.Router, + private readonly name: string, + private readonly extLogger: ILogger) { } get use() { - return new ExpressRouteWrapper(this.router, this.name, 'use'); + return new ExpressRouteWrapper(this.router, this.name, 'use', this.extLogger); } get get() { - return new ExpressRouteWrapper(this.router, this.name, 'get'); + return new ExpressRouteWrapper(this.router, this.name, 'get', this.extLogger); } get put() { - return new ExpressRouteWrapper(this.router, this.name, 'put'); + return new ExpressRouteWrapper(this.router, this.name, 'put', this.extLogger); } get post() { - return new ExpressRouteWrapper(this.router, this.name, 'post'); + return new ExpressRouteWrapper(this.router, this.name, 'post', this.extLogger); } get delete() { - return new ExpressRouteWrapper(this.router, this.name, 'delete'); + return new ExpressRouteWrapper(this.router, this.name, 'delete', this.extLogger); } } @@ -41,7 +43,8 @@ export class ExpressRouteWrapper implements IExtensionRESTRoute { constructor(private readonly router: express.Router, private readonly name: string, - private readonly func: 'get' | 'use' | 'put' | 'post' | 'delete') { + private readonly func: 'get' | 'use' | 'put' | 'post' | 'delete', + private readonly extLogger: ILogger) { } private getAuthMWs(minRole: UserRoles) { @@ -59,14 +62,14 @@ export class ExpressRouteWrapper implements IExtensionRESTRoute { }, RenderingMWs.renderResult ]))); - Logger.silly(`[ExtensionRest:${this.name}]`, `Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + this.extLogger.silly(`Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); } public rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise) { - const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p))); + const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p))); this.router[this.func](fullPaths, ...this.getAuthMWs(minRole), mw); - Logger.silly(`[ExtensionRest:${this.name}]`, `Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + this.extLogger.silly(`Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); } } diff --git a/src/backend/model/extension/ExtensionApp.ts b/src/backend/model/extension/ExtensionApp.ts new file mode 100644 index 00000000..90ad6a69 --- /dev/null +++ b/src/backend/model/extension/ExtensionApp.ts @@ -0,0 +1,18 @@ +import {IExtensionApp} from './IExtension'; +import {ObjectManagers} from '../ObjectManagers'; +import {Config} from '../../../common/config/private/Config'; +import {Server} from '../../server'; + +export class ExtensionApp implements IExtensionApp { + get config() { + return Config; + } + + get expressApp() { + return Server.getInstance().app; + } + + get objectManagers() { + return ObjectManagers.getInstance(); + } +} diff --git a/src/backend/model/extension/ExtensionDB.ts b/src/backend/model/extension/ExtensionDB.ts new file mode 100644 index 00000000..c0bd1ed4 --- /dev/null +++ b/src/backend/model/extension/ExtensionDB.ts @@ -0,0 +1,26 @@ +import {IExtensionDB} from './IExtension'; +import {SQLConnection} from '../database/SQLConnection'; +import {Connection} from 'typeorm'; +import {ILogger} from '../../Logger'; + +export class ExtensionDB implements IExtensionDB { + + constructor(private readonly extLogger: ILogger) { + } + + // eslint-disable-next-line @typescript-eslint/ban-types + _getAllTables(): Function[] { + return SQLConnection.getEntries(); + } + + getSQLConnection(): Promise { + return SQLConnection.getConnection(); + } + + // eslint-disable-next-line @typescript-eslint/ban-types + async setExtensionTables(tables: Function[]): Promise { + this.extLogger.debug('Adding ' + tables?.length + ' extension tables to DB'); + await SQLConnection.addEntries(tables); + } + +} diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 995eb933..2266fcdb 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -5,11 +5,13 @@ import * as path from 'path'; import {IObjectManager} from '../database/IObjectManager'; import {createLoggerWrapper, Logger} from '../../Logger'; import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; -import {ObjectManagers} from '../ObjectManagers'; import {Server} from '../../server'; import {ExtensionEvent} from './ExtensionEvent'; import {ExpressRouterWrapper} from './ExpressRouterWrapper'; import * as express from 'express'; +import {ExtensionApp} from './ExtensionApp'; +import {ExtensionDB} from './ExtensionDB'; +import {SQLConnection} from '../database/SQLConnection'; const LOG_TAG = '[ExtensionManager]'; @@ -17,36 +19,45 @@ export class ExtensionManager implements IObjectManager { public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension'; - events: IExtensionEvents = { - gallery: { - MetadataLoader: { - loadPhotoMetadata: new ExtensionEvent(), - loadVideoMetadata: new ExtensionEvent() - }, - CoverManager: { - getCoverForDirectory: new ExtensionEvent(), - getCoverForAlbum: new ExtensionEvent(), - invalidateDirectoryCovers: new ExtensionEvent(), - }, - DiskManager: { - scanDirectory: new ExtensionEvent() - }, - ImageRenderer: { - render: new ExtensionEvent() - } - } - }; + events: IExtensionEvents; extObjects: { [key: string]: IExtensionObject } = {}; router: express.Router; + constructor() { + this.initEvents(); + } + public async init() { this.extObjects = {}; + this.initEvents(); this.router = express.Router(); Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, this.router); this.loadExtensionsList(); await this.initExtensions(); } + private initEvents() { + this.events = { + gallery: { + MetadataLoader: { + loadPhotoMetadata: new ExtensionEvent(), + loadVideoMetadata: new ExtensionEvent() + }, + CoverManager: { + getCoverForDirectory: new ExtensionEvent(), + getCoverForAlbum: new ExtensionEvent(), + invalidateDirectoryCovers: new ExtensionEvent(), + }, + DiskManager: { + scanDirectory: new ExtensionEvent() + }, + ImageRenderer: { + render: new ExtensionEvent() + } + } + }; + } + public loadExtensionsList() { Logger.debug(LOG_TAG, 'Loading extension list from ' + ProjectPath.ExtensionFolder); if (!fs.existsSync(ProjectPath.ExtensionFolder)) { @@ -79,19 +90,14 @@ export class ExtensionManager implements IObjectManager { private createExtensionObject(name: string): IExtensionObject { if (!this.extObjects[name]) { - const rw = new ExpressRouterWrapper(this.router, name); + const logger = createLoggerWrapper(`[Extension][${name}]`); this.extObjects[name] = { - _app: { - get objectManagers() { - return ObjectManagers.getInstance(); - }, - expressApp: Server.getInstance().app, - config: Config - }, + _app: new ExtensionApp(), + db: new ExtensionDB(logger), paths: ProjectPath, - Logger: createLoggerWrapper(`[Extension: ${name}]`), + Logger: logger, events: this.events, - RESTApi: rw + RESTApi: new ExpressRouterWrapper(this.router, name, logger) }; } return this.extObjects[name]; @@ -104,6 +110,10 @@ export class ExtensionManager implements IObjectManager { await ext?.init(this.createExtensionObject(extName)); } }); + if (Config.Extensions.cleanUpUnusedTables) { + // Clean up tables after all Extension was initialized. + await SQLConnection.removeUnusedTables(); + } } private async cleanUpExtensions() { @@ -117,6 +127,7 @@ export class ExtensionManager implements IObjectManager { public async cleanUp() { + this.initEvents(); // reset events await this.cleanUpExtensions(); Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, express.Router()); this.extObjects = {}; diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index 6a366ff7..b80d5d02 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -6,6 +6,7 @@ import {ProjectPathClass} from '../../ProjectPath'; import {ILogger} from '../../Logger'; import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; import {ParamsDictionary} from 'express-serve-static-core'; +import {Connection, EntitySchema} from 'typeorm'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -63,8 +64,20 @@ export interface IExtensionApp { } export interface IExtensionRESTRoute { + /** + * Sends a pigallery2 standard JSON object with payload or error message back to the client. + * @param paths + * @param minRole + * @param cb + */ jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): void; + /** + * Exposes a standard expressjs middleware + * @param paths + * @param minRole + * @param mw + */ rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): void; } @@ -76,11 +89,39 @@ export interface IExtensionRESTApi { delete: IExtensionRESTRoute; } +export interface IExtensionDB { + /** + * Returns with a typeorm SQL connection + */ + getSQLConnection(): Promise; + + /** + * Adds SQL tables to typeorm + * @param tables + */ + // eslint-disable-next-line @typescript-eslint/ban-types + setExtensionTables(tables: Function[]): Promise; + + /** + * Exposes all tables. You can use this if you van to have a foreign key to a built in table. + * Use with caution. This exposes the app's internal working. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + _getAllTables(): Function[]; +} + export interface IExtensionObject { /** - * Inner functionality of the app. Use this wit caution + * Inner functionality of the app. Use this with caution. + * If you want to go deeper than the standard exposed APIs, you can try doing so here. */ _app: IExtensionApp; + + /** + * Create new SQL tables and access SQL connection + */ + db: IExtensionDB; + /** * Paths to the main components of the app. */ @@ -104,6 +145,10 @@ export interface IExtensionObject { * Extension interface. All extension is expected to implement and export these methods */ export interface IServerExtension { + /** + * Extension init function. Extension should at minimum expose this function. + * @param extension + */ init(extension: IExtensionObject): Promise; cleanUp?: (extension: IExtensionObject) => Promise; diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index e88b65b6..1eafe4d6 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -1019,6 +1019,15 @@ export class ServerServiceConfig extends ClientServiceConfig { export class ServerExtensionsConfig { @ConfigProperty({volatile: true}) list: string[] = []; + + @ConfigProperty({ + tags: { + name: $localize`Clean up unused tables`, + priority: ConfigPriority.underTheHood, + }, + description: $localize`Automatically removes all tables from the DB that are not used anymore.`, + }) + cleanUpUnusedTables: boolean = true; } @SubConfigClass({softReadonly: true}) From 75d277040d7f25bb3da142dd73fa35cf1b5ee457 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Mon, 13 Nov 2023 16:51:25 +0100 Subject: [PATCH 05/15] Add basic configuring options #753 --- src/backend/middlewares/RenderingMWs.ts | 3 +- src/backend/middlewares/admin/SettingsMWs.ts | 5 +- .../model/extension/ExtensionConfigWrapper.ts | 50 +++++++++ .../model/extension/ExtensionManager.ts | 30 ++---- .../model/extension/ExtensionObject.ts | 31 ++++++ src/backend/model/extension/IExtension.ts | 33 ++++-- src/common/config/private/Config.ts | 20 +--- src/common/config/private/PrivateConfig.ts | 4 +- .../settings-entry.component.ts | 100 ++++++++++-------- .../routers/admin/SettingsRouter.ts | 3 +- .../unit/middlewares/admin/SettingsMWs.ts | 3 +- 11 files changed, 185 insertions(+), 97 deletions(-) create mode 100644 src/backend/model/extension/ExtensionConfigWrapper.ts create mode 100644 src/backend/model/extension/ExtensionObject.ts diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index c73af26e..404c1814 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -9,6 +9,7 @@ import {SharingDTO} from '../../common/entities/SharingDTO'; import {Utils} from '../../common/Utils'; import {LoggerRouter} from '../routes/LoggerRouter'; import {TAGS} from '../../common/config/public/ClientConfig'; +import {ExtensionConfigWrapper} from '../model/extension/ExtensionConfigWrapper'; const forcedDebug = process.env['NODE_ENV'] === 'debug'; @@ -107,7 +108,7 @@ export class RenderingMWs { req: Request, res: Response ): Promise { - const originalConf = await Config.original(); + const originalConf = await ExtensionConfigWrapper.original(); // These are sensitive information, do not send to the client side originalConf.Server.sessionSecret = null; const message = new Message( diff --git a/src/backend/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index fe688644..6f745e03 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -6,6 +6,7 @@ import {ConfigDiagnostics} from '../../model/diagnostics/ConfigDiagnostics'; import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; import {TAGS} from '../../../common/config/public/ClientConfig'; import {ObjectManagers} from '../../model/ObjectManagers'; +import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper'; const LOG_TAG = '[SettingsMWs]'; @@ -28,7 +29,7 @@ export class SettingsMWs { try { let settings = req.body.settings; // Top level settings JSON const settingsPath: string = req.body.settingsPath; // Name of the top level settings - const transformer = await Config.original(); + const transformer = await ExtensionConfigWrapper.original(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore transformer[settingsPath] = settings; @@ -37,7 +38,7 @@ export class SettingsMWs { settings = ConfigClassBuilder.attachPrivateInterface(transformer[settingsPath]).toJSON({ skipTags: {secret: true} as TAGS }); - const original = await Config.original(); + const original = await ExtensionConfigWrapper.original(); // only updating explicitly set config (not saving config set by the diagnostics) // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts new file mode 100644 index 00000000..59246c48 --- /dev/null +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -0,0 +1,50 @@ +import {IConfigClass} from '../../../../node_modules/typeconfig/common'; +import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; +import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; +import {IExtensionConfig} from './IExtension'; +import {Utils} from '../../../common/Utils'; +import {ObjectManagers} from '../ObjectManagers'; + +/** + * Wraps to original config and makes sure all extension related config is loaded + */ +export class ExtensionConfigWrapper { + static async original(): Promise { + const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); + try { + await pc.load(); + for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { + ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); + } + } catch (e) { + console.error('Error during loading original config. Reverting to defaults.'); + console.error(e); + } + return pc; + } +} + +export class ExtensionConfig implements IExtensionConfig { + public template: new() => C; + + constructor(private readonly extensionId: string) { + } + + public getConfig(): C { + return Config.Extensions.configs[this.extensionId] as C; + } + + public setTemplate(template: new() => C): void { + this.template = template; + this.loadToConfig(Config); + } + + loadToConfig(config: PrivateConfigClass) { + if (!this.template) { + return; + } + const conf = ConfigClassBuilder.attachPrivateInterface(new this.template()); + conf.__loadJSONObject(Utils.clone(config.Extensions.configs[this.extensionId] || {})); + config.Extensions.configs[this.extensionId] = conf; + } +} diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 2266fcdb..3bef863b 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -3,15 +3,13 @@ import {Config} from '../../../common/config/private/Config'; import * as fs from 'fs'; import * as path from 'path'; import {IObjectManager} from '../database/IObjectManager'; -import {createLoggerWrapper, Logger} from '../../Logger'; +import {Logger} from '../../Logger'; import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; import {Server} from '../../server'; import {ExtensionEvent} from './ExtensionEvent'; -import {ExpressRouterWrapper} from './ExpressRouterWrapper'; import * as express from 'express'; -import {ExtensionApp} from './ExtensionApp'; -import {ExtensionDB} from './ExtensionDB'; import {SQLConnection} from '../database/SQLConnection'; +import {ExtensionObject} from './ExtensionObject'; const LOG_TAG = '[ExtensionManager]'; @@ -20,7 +18,7 @@ export class ExtensionManager implements IObjectManager { public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension'; events: IExtensionEvents; - extObjects: { [key: string]: IExtensionObject } = {}; + extObjects: { [key: string]: ExtensionObject } = {}; router: express.Router; constructor() { @@ -65,15 +63,15 @@ export class ExtensionManager implements IObjectManager { } Config.Extensions.list = fs - .readdirSync(ProjectPath.ExtensionFolder) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); Config.Extensions.list.sort(); Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list)); } - private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { + private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { for (let i = 0; i < Config.Extensions.list.length; ++i) { const extName = Config.Extensions.list[i]; const extPath = path.join(ProjectPath.ExtensionFolder, extName); @@ -88,17 +86,9 @@ export class ExtensionManager implements IObjectManager { } } - private createExtensionObject(name: string): IExtensionObject { + private createExtensionObject(name: string): IExtensionObject { if (!this.extObjects[name]) { - const logger = createLoggerWrapper(`[Extension][${name}]`); - this.extObjects[name] = { - _app: new ExtensionApp(), - db: new ExtensionDB(logger), - paths: ProjectPath, - Logger: logger, - events: this.events, - RESTApi: new ExpressRouterWrapper(this.router, name, logger) - }; + this.extObjects[name] = new ExtensionObject(name, this.router, this.events); } return this.extObjects[name]; } diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts new file mode 100644 index 00000000..f21e28ae --- /dev/null +++ b/src/backend/model/extension/ExtensionObject.ts @@ -0,0 +1,31 @@ +import {IExtensionEvents, IExtensionObject} from './IExtension'; +import {ExtensionApp} from './ExtensionApp'; +import {ExtensionConfig} from './ExtensionConfigWrapper'; +import {ExtensionDB} from './ExtensionDB'; +import {ProjectPath} from '../../ProjectPath'; +import {ExpressRouterWrapper} from './ExpressRouterWrapper'; +import {createLoggerWrapper} from '../../Logger'; +import * as express from 'express'; + +export class ExtensionObject implements IExtensionObject { + + public readonly _app; + public readonly config; + public readonly db; + public readonly paths; + public readonly Logger; + public readonly events; + public readonly RESTApi; + + constructor(public readonly extensionId: string, extensionRouter: express.Router, events: IExtensionEvents) { + const logger = createLoggerWrapper(`[Extension][${extensionId}]`); + this._app = new ExtensionApp(); + this.config = new ExtensionConfig(extensionId); + this.db = new ExtensionDB(logger); + this.paths = ProjectPath; + this.Logger = logger; + this.events = events; + this.RESTApi = new ExpressRouterWrapper(extensionRouter, extensionId, logger); + } + +} diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index b80d5d02..3375caaf 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -6,7 +6,7 @@ import {ProjectPathClass} from '../../ProjectPath'; import {ILogger} from '../../Logger'; import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; import {ParamsDictionary} from 'express-serve-static-core'; -import {Connection, EntitySchema} from 'typeorm'; +import {Connection} from 'typeorm'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -66,17 +66,17 @@ export interface IExtensionApp { export interface IExtensionRESTRoute { /** * Sends a pigallery2 standard JSON object with payload or error message back to the client. - * @param paths - * @param minRole - * @param cb + * @param paths RESTapi path, relative to the extension base endpoint + * @param minRole set to null to omit auer check (ie make the endpoint public) + * @param cb function callback */ jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): void; /** * Exposes a standard expressjs middleware - * @param paths - * @param minRole - * @param mw + * @param paths RESTapi path, relative to the extension base endpoint + * @param minRole set to null to omit auer check (ie make the endpoint public) + * @param mw expressjs middleware */ rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): void; } @@ -110,13 +110,24 @@ export interface IExtensionDB { _getAllTables(): Function[]; } -export interface IExtensionObject { +export interface IExtensionConfig { + setTemplate(template: new() => C): void; + + getConfig(): C; +} + +export interface IExtensionObject { /** * Inner functionality of the app. Use this with caution. * If you want to go deeper than the standard exposed APIs, you can try doing so here. */ _app: IExtensionApp; + /** + * Create extension related configuration + */ + config: IExtensionConfig; + /** * Create new SQL tables and access SQL connection */ @@ -144,12 +155,12 @@ export interface IExtensionObject { /** * Extension interface. All extension is expected to implement and export these methods */ -export interface IServerExtension { +export interface IServerExtension { /** * Extension init function. Extension should at minimum expose this function. * @param extension */ - init(extension: IExtensionObject): Promise; + init(extension: IExtensionObject): Promise; - cleanUp?: (extension: IExtensionObject) => Promise; + cleanUp?: (extension: IExtensionObject) => Promise; } diff --git a/src/common/config/private/Config.ts b/src/common/config/private/Config.ts index 2cd38a1b..d12e5b41 100644 --- a/src/common/config/private/Config.ts +++ b/src/common/config/private/Config.ts @@ -13,7 +13,7 @@ const upTime = new Date().toISOString(); // TODO: Refactor Config to be injectable globally. // This is a bad habit to let the Config know if its in a testing env. const isTesting = process.env['NODE_ENV'] == true || ['afterEach', 'after', 'beforeEach', 'before', 'describe', 'it'] - .every((fn) => (global as any)[fn] instanceof Function); + .every((fn) => (global as any)[fn] instanceof Function); @ConfigClass & ServerConfig>({ configPath: path.join(__dirname, !isTesting ? './../../../../config.json' : './../../../../test/backend/tmp/config.json'), @@ -76,30 +76,20 @@ export class PrivateConfigClass extends ServerConfig { } this.Environment.appVersion = - require('../../../../package.json').version; + require('../../../../package.json').version; this.Environment.buildTime = - require('../../../../package.json').buildTime; + require('../../../../package.json').buildTime; this.Environment.buildCommitHash = - require('../../../../package.json').buildCommitHash; + require('../../../../package.json').buildCommitHash; this.Environment.upTime = upTime; this.Environment.isDocker = !!process.env.PI_DOCKER; } - async original(): Promise { - const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); - try { - await pc.load(); - } catch (e) { - console.error('Error during loading original config. Reverting to defaults.'); - console.error(e); - } - return pc; - } } export const Config = ConfigClassBuilder.attachInterface( - new PrivateConfigClass() + new PrivateConfigClass() ); try { Config.loadSync(); diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 1eafe4d6..8c952526 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -1014,12 +1014,14 @@ export class ServerServiceConfig extends ClientServiceConfig { } - @SubConfigClass({softReadonly: true}) export class ServerExtensionsConfig { @ConfigProperty({volatile: true}) list: string[] = []; + @ConfigProperty({type: 'object'}) + configs: Record = {}; + @ConfigProperty({ tags: { name: $localize`Clean up unused tables`, diff --git a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts index cdbc0dc6..5f2d2b4f 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts +++ b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts @@ -59,7 +59,7 @@ interface IState { ], }) export class SettingsEntryComponent - implements ControlValueAccessor, Validator, OnChanges { + implements ControlValueAccessor, Validator, OnChanges { name: string; required: boolean; dockerWarning: boolean; @@ -79,7 +79,10 @@ export class SettingsEntryComponent public arrayType: string; public uiType: string; newThemeModalRef: any; - iconModal: { ref?: any, error?: string }; + iconModal: { + ref?: any, + error?: string + }; @Input() noChangeDetection = false; public readonly ConfigStyle = ConfigStyle; protected readonly SortByTypes = SortByTypes; @@ -101,9 +104,9 @@ export class SettingsEntryComponent for (let i = 0; i < this.state.value?.length; ++i) { for (const k of Object.keys(this.state.value[i].__state)) { if (!Utils.equalsFilter( - this.state.value[i]?.__state[k]?.value, - this.state.default[i] ? this.state.default[i][k] : undefined, - ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { + this.state.value[i]?.__state[k]?.value, + this.state.default[i] ? this.state.default[i][k] : undefined, + ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { return true; } @@ -129,7 +132,7 @@ export class SettingsEntryComponent get defaultStr(): string { if (this.type === 'SearchQuery') { return ( - '\'' + this.searchQueryParserService.stringify(this.state.default) + '\'' + '\'' + this.searchQueryParserService.stringify(this.state.default) + '\'' ); } @@ -143,8 +146,8 @@ export class SettingsEntryComponent get StringValue(): string { if ( - this.state.type === 'array' && - (this.state.arrayType === 'string' || this.isNumberArray) + this.state.type === 'array' && + (this.state.arrayType === 'string' || this.isNumberArray) ) { return (this.state.value || []).join(';'); } @@ -162,8 +165,8 @@ export class SettingsEntryComponent set StringValue(value: string) { if ( - this.state.type === 'array' && - (this.state.arrayType === 'string' || this.isNumberArray) + this.state.type === 'array' && + (this.state.arrayType === 'string' || this.isNumberArray) ) { value = value.replace(new RegExp(',', 'g'), ';'); if (!this.allowSpaces) { @@ -172,14 +175,14 @@ export class SettingsEntryComponent this.state.value = value.split(';').filter((v: string) => v !== ''); if (this.isNumberArray) { this.state.value = this.state.value - .map((v: string) => parseFloat(v)) - .filter((v: number) => !isNaN(v)); + .map((v: string) => parseFloat(v)) + .filter((v: number) => !isNaN(v)); } return; } - if (typeof this.state.value === 'object') { this.state.value = JSON.parse(value); + return; } this.state.value = value; @@ -194,11 +197,13 @@ export class SettingsEntryComponent key: 'default', value: $localize`default` }, ...(this.state.rootConfig as any).__state.availableThemes.value - .map((th: ThemeConfig) => ({key: th.name, value: th.name}))]; + .map((th: ThemeConfig) => ({key: th.name, value: th.name}))]; } - get SelectedThemeSettings(): { theme: string } { + get SelectedThemeSettings(): { + theme: string + } { return (this.state.value as ThemeConfig[]).find(th => th.name === (this.state.rootConfig as any).__state.selectedTheme.value) || {theme: 'N/A'}; } @@ -241,16 +246,16 @@ export class SettingsEntryComponent this.uiType = CustomSettingsEntries.getFullName(this.state); } if (!this.state.isEnumType && - !this.state.isEnumArrayType && - this.type !== 'boolean' && - this.type !== 'SearchQuery' && - !CustomSettingsEntries.iS(this.state) && - this.arrayType !== 'MapLayers' && - this.arrayType !== 'NavigationLinkConfig' && - this.arrayType !== 'MapPathGroupConfig' && - this.arrayType !== 'MapPathGroupThemeConfig' && - this.arrayType !== 'JobScheduleConfig' && - this.arrayType !== 'UserConfig') { + !this.state.isEnumArrayType && + this.type !== 'boolean' && + this.type !== 'SearchQuery' && + !CustomSettingsEntries.iS(this.state) && + this.arrayType !== 'MapLayers' && + this.arrayType !== 'NavigationLinkConfig' && + this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'MapPathGroupThemeConfig' && + this.arrayType !== 'JobScheduleConfig' && + this.arrayType !== 'UserConfig') { this.uiType = 'StringInput'; } if (this.type === this.state.tags?.uiType) { @@ -273,18 +278,18 @@ export class SettingsEntryComponent this.name = this.state?.tags?.name; if (this.name) { this.idName = - this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-'); + this.GUID + this.name.toLowerCase().replace(new RegExp(' ', 'gm'), '-'); } this.isNumberArray = - this.state.arrayType === 'unsignedInt' || - this.state.arrayType === 'integer' || - this.state.arrayType === 'float' || - this.state.arrayType === 'positiveFloat'; + this.state.arrayType === 'unsignedInt' || + this.state.arrayType === 'integer' || + this.state.arrayType === 'float' || + this.state.arrayType === 'positiveFloat'; this.isNumber = - this.state.type === 'unsignedInt' || - this.state.type === 'integer' || - this.state.type === 'float' || - this.state.type === 'positiveFloat'; + this.state.type === 'unsignedInt' || + this.state.type === 'integer' || + this.state.type === 'float' || + this.state.type === 'positiveFloat'; if (this.isNumber) { @@ -306,11 +311,16 @@ export class SettingsEntryComponent } } - getOptionsView(state: IState & { optionsView?: { key: number | string; value: string | number }[] }) { + getOptionsView(state: IState & { + optionsView?: { + key: number | string; + value: string | number + }[] + }) { if (!state.optionsView) { const eClass = state.isEnumType - ? state.type - : state.arrayType; + ? state.type + : state.arrayType; if (state.tags?.uiOptions) { state.optionsView = state.tags?.uiOptions.map(o => ({ key: o, @@ -325,11 +335,11 @@ export class SettingsEntryComponent validate(): ValidationErrors { if ( - !this.required || - (this.state && - typeof this.state.value !== 'undefined' && - this.state.value !== null && - this.state.value !== '') + !this.required || + (this.state && + typeof this.state.value !== 'undefined' && + this.state.value !== null && + this.state.value !== '') ) { return null; } @@ -386,8 +396,8 @@ export class SettingsEntryComponent removeLayer(layer: MapLayers): void { this.state.value.splice( - this.state.value.indexOf(layer), - 1 + this.state.value.indexOf(layer), + 1 ); } @@ -395,7 +405,7 @@ export class SettingsEntryComponent addNewTheme(): void { const availableThemes = (this.state.rootConfig as any).__state.availableThemes; if (!this.newThemeName || - (availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) { + (availableThemes.value as ThemeConfig[]).find(th => th.name === this.newThemeName)) { return; } this.state.value = this.newThemeName; diff --git a/test/backend/integration/routers/admin/SettingsRouter.ts b/test/backend/integration/routers/admin/SettingsRouter.ts index 5aa90736..cdc98838 100644 --- a/test/backend/integration/routers/admin/SettingsRouter.ts +++ b/test/backend/integration/routers/admin/SettingsRouter.ts @@ -7,6 +7,7 @@ import {ProjectPath} from '../../../../../src/backend/ProjectPath'; import {TAGS} from '../../../../../src/common/config/public/ClientConfig'; import {ObjectManagers} from '../../../../../src/backend/model/ObjectManagers'; import {UserRoles} from '../../../../../src/common/entities/UserDTO'; +import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper'; process.env.NODE_ENV = 'test'; const chai: any = require('chai'); @@ -34,7 +35,7 @@ describe('SettingsRouter', () => { it('it should GET the settings', async () => { Config.Users.authenticationRequired = false; Config.Users.unAuthenticatedUserRole = UserRoles.Admin; - const originalSettings = await Config.original(); + const originalSettings = await ExtensionConfigWrapper.original(); const srv = new Server(); await srv.onStarted.wait(); const result = await chai.request(srv.App) diff --git a/test/backend/unit/middlewares/admin/SettingsMWs.ts b/test/backend/unit/middlewares/admin/SettingsMWs.ts index b8a49a4f..ef85113d 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -9,6 +9,7 @@ import {UserRoles} from '../../../../../src/common/entities/UserDTO'; import {ConfigClassBuilder} from '../../../../../node_modules/typeconfig/node'; import * as fs from 'fs'; import * as path from 'path'; +import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper'; declare const describe: any; @@ -74,7 +75,7 @@ describe('Settings middleware', () => { expect(Config.Users.enforcedUsers.length).to.be.equal(1); expect(Config.Users.enforcedUsers[0].name).to.be.equal('Apple'); expect(Config.Users.enforcedUsers.length).to.be.equal(1); - Config.original().then((cfg) => { + ExtensionConfigWrapper.original().then((cfg) => { try { expect(cfg.Users.enforcedUsers.length).to.be.equal(1); expect(cfg.Users.enforcedUsers[0].name).to.be.equal('Apple'); From a323634351af43bc9f28cf56048fb832eaa88b9d Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Thu, 16 Nov 2023 23:41:05 +0100 Subject: [PATCH 06/15] Fixing tests after adding extension manager #753 --- benchmark/BenchmarkRunner.ts | 6 +- src/backend/model/ObjectManagers.ts | 162 ++++++++++-------- .../model/extension/ExtensionConfigWrapper.ts | 6 +- .../model/extension/ExtensionDecorator.ts | 5 + .../model/extension/ExtensionManager.ts | 6 + src/backend/server.ts | 16 +- src/common/config/private/PrivateConfig.ts | 3 +- src/common/config/public/ClientConfig.ts | 19 ++ test/backend/DBTestHelper.ts | 17 +- .../integration/routers/GalleryRouter.ts | 8 +- .../integration/routers/PublicRouter.ts | 4 +- .../integration/routers/SharingRouter.ts | 4 +- .../backend/integration/routers/UserRouter.ts | 18 +- .../routers/admin/SettingsRouter.ts | 2 +- .../unit/middlewares/admin/SettingsMWs.ts | 2 +- .../unit/model/sql/AlbumManager.spec.ts | 2 +- .../unit/model/sql/CoverManager.spec.ts | 2 +- .../unit/model/sql/SearchManager.spec.ts | 2 +- .../model/threading/DiskManagerWorker.spec.ts | 2 + .../model/threading/MetaDataLoader.spec.ts | 3 +- 20 files changed, 169 insertions(+), 120 deletions(-) diff --git a/benchmark/BenchmarkRunner.ts b/benchmark/BenchmarkRunner.ts index 6615a006..3d60562c 100644 --- a/benchmark/BenchmarkRunner.ts +++ b/benchmark/BenchmarkRunner.ts @@ -121,7 +121,7 @@ export class BenchmarkRunner { const bm = new Benchmark('List directory', req, async (): Promise => { await ObjectManagers.reset(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }, null, async (): Promise => { Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low; @@ -135,7 +135,7 @@ export class BenchmarkRunner { async bmListPersons(): Promise { const bm = new Benchmark('Listing Faces', Utils.clone(this.requestTemplate), async (): Promise => { await ObjectManagers.reset(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }, null, async (): Promise => { Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low; @@ -289,7 +289,7 @@ export class BenchmarkRunner { await fs.promises.rm(ProjectPath.DBFolder, {recursive: true, force: true}); Config.Database.type = DatabaseType.sqlite; Config.Jobs.scheduled = []; - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }; private async setupDB(): Promise { diff --git a/src/backend/model/ObjectManagers.ts b/src/backend/model/ObjectManagers.ts index b9023123..ed70be7e 100644 --- a/src/backend/model/ObjectManagers.ts +++ b/src/backend/model/ObjectManagers.ts @@ -34,11 +34,98 @@ export class ObjectManagers { private locationManager: LocationManager; private albumManager: AlbumManager; private extensionManager: ExtensionManager; + private initDone = false; constructor() { this.managers = []; } + public static getInstance(): ObjectManagers { + if (!this.instance) { + this.instance = new ObjectManagers(); + } + return this.instance; + } + + public static async reset(): Promise { + Logger.silly(LOG_TAG, 'Object manager reset begin'); + if (ObjectManagers.isReady()) { + if ( + ObjectManagers.getInstance().IndexingManager && + ObjectManagers.getInstance().IndexingManager.IsSavingInProgress + ) { + await ObjectManagers.getInstance().IndexingManager.SavingReady; + } + for (const manager of ObjectManagers.getInstance().managers) { + if (manager === ObjectManagers.getInstance().versionManager) { + continue; + } + if (manager.cleanUp) { + await manager.cleanUp(); + } + } + } + + await SQLConnection.close(); + this.instance = null; + Logger.debug(LOG_TAG, 'Object manager reset done'); + } + + public static isReady(): boolean { + return this.instance && this.instance.initDone; + } + + + public async init(): Promise { + if (this.initDone) { + return; + } + await SQLConnection.init(); + await this.initManagers(); + Logger.debug(LOG_TAG, 'SQL DB inited'); + this.initDone = true; + } + + private async initManagers(): Promise { + this.AlbumManager = new AlbumManager(); + this.GalleryManager = new GalleryManager(); + this.IndexingManager = new IndexingManager(); + this.PersonManager = new PersonManager(); + this.CoverManager = new CoverManager(); + this.SearchManager = new SearchManager(); + this.SharingManager = new SharingManager(); + this.UserManager = new UserManager(); + this.VersionManager = new VersionManager(); + this.JobManager = new JobManager(); + this.LocationManager = new LocationManager(); + this.ExtensionManager = new ExtensionManager(); + + for (const manager of ObjectManagers.getInstance().managers) { + if (manager === ObjectManagers.getInstance().versionManager) { + continue; + } + if (manager.init) { + await manager.init(); + } + } + } + + public async onDataChange( + changedDir: ParentDirectoryDTO = null + ): Promise { + await this.VersionManager.onNewDataVersion(); + + for (const manager of this.managers) { + if (manager === this.versionManager) { + continue; + } + if (manager.onNewDataVersion) { + await manager.onNewDataVersion(changedDir); + } + } + } + + get VersionManager(): VersionManager { return this.versionManager; } @@ -182,79 +269,4 @@ export class ObjectManagers { this.extensionManager = value; this.managers.push(this.extensionManager as IObjectManager); } - - public static getInstance(): ObjectManagers { - if (this.instance === null) { - this.instance = new ObjectManagers(); - } - return this.instance; - } - - public static async reset(): Promise { - Logger.silly(LOG_TAG, 'Object manager reset begin'); - if ( - ObjectManagers.getInstance().IndexingManager && - ObjectManagers.getInstance().IndexingManager.IsSavingInProgress - ) { - await ObjectManagers.getInstance().IndexingManager.SavingReady; - } - for (const manager of ObjectManagers.getInstance().managers) { - if (manager === ObjectManagers.getInstance().versionManager) { - continue; - } - if (manager.cleanUp) { - await manager.cleanUp(); - } - } - - await SQLConnection.close(); - this.instance = null; - Logger.debug(LOG_TAG, 'Object manager reset done'); - } - - public static async InitManagers(): Promise { - await ObjectManagers.reset(); - await SQLConnection.init(); - await this.initManagers(); - Logger.debug(LOG_TAG, 'SQL DB inited'); - } - - private static async initManagers(): Promise { - ObjectManagers.getInstance().AlbumManager = new AlbumManager(); - ObjectManagers.getInstance().GalleryManager = new GalleryManager(); - ObjectManagers.getInstance().IndexingManager = new IndexingManager(); - ObjectManagers.getInstance().PersonManager = new PersonManager(); - ObjectManagers.getInstance().CoverManager = new CoverManager(); - ObjectManagers.getInstance().SearchManager = new SearchManager(); - ObjectManagers.getInstance().SharingManager = new SharingManager(); - ObjectManagers.getInstance().UserManager = new UserManager(); - ObjectManagers.getInstance().VersionManager = new VersionManager(); - ObjectManagers.getInstance().JobManager = new JobManager(); - ObjectManagers.getInstance().LocationManager = new LocationManager(); - ObjectManagers.getInstance().ExtensionManager = new ExtensionManager(); - - for (const manager of ObjectManagers.getInstance().managers) { - if (manager === ObjectManagers.getInstance().versionManager) { - continue; - } - if (manager.init) { - await manager.init(); - } - } - } - - public async onDataChange( - changedDir: ParentDirectoryDTO = null - ): Promise { - await this.VersionManager.onNewDataVersion(); - - for (const manager of this.managers) { - if (manager === this.versionManager) { - continue; - } - if (manager.onNewDataVersion) { - await manager.onNewDataVersion(changedDir); - } - } - } } diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index 59246c48..534b72ce 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -13,8 +13,10 @@ export class ExtensionConfigWrapper { const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); try { await pc.load(); - for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { - ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); + if (ObjectManagers.isReady()) { + for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { + ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); + } } } catch (e) { console.error('Error during loading original config. Reverting to defaults.'); diff --git a/src/backend/model/extension/ExtensionDecorator.ts b/src/backend/model/extension/ExtensionDecorator.ts index dbaae83c..ddff3c3f 100644 --- a/src/backend/model/extension/ExtensionDecorator.ts +++ b/src/backend/model/extension/ExtensionDecorator.ts @@ -8,8 +8,13 @@ export const ExtensionDecorator = (fn: (ee: IExtensionEvents) = propertyName: string, descriptor: PropertyDescriptor ) => { + const targetMethod = descriptor.value; descriptor.value = async function(...args: I) { + if (!ObjectManagers.isReady()) { + return await targetMethod.apply(this, args); + } + const event = fn(ObjectManagers.getInstance().ExtensionManager.events) as ExtensionEvent; const eventObj = {stopPropagation: false}; const input = await event.triggerBefore({inputs: args}, eventObj); diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 3bef863b..5d761e96 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -28,6 +28,9 @@ export class ExtensionManager implements IObjectManager { public async init() { this.extObjects = {}; this.initEvents(); + if (!Config.Extensions.enabled) { + return; + } this.router = express.Router(); Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, this.router); this.loadExtensionsList(); @@ -117,6 +120,9 @@ export class ExtensionManager implements IObjectManager { public async cleanUp() { + if (!Config.Extensions.enabled) { + return; + } this.initEvents(); // reset events await this.cleanUpExtensions(); Server.getInstance().app.use(ExtensionManager.EXTENSION_API_PATH, express.Router()); diff --git a/src/backend/server.ts b/src/backend/server.ts index edad7b54..6e9899e5 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -38,7 +38,7 @@ export class Server { static instance: Server = null; public static getInstance(): Server { - if (this.instance === null) { + if (!this.instance) { this.instance = new Server(); } return this.instance; @@ -54,11 +54,16 @@ export class Server { this.init().catch(console.error); } - get App(): any { + get Server(): HttpServer { return this.server; } async init(): Promise { + + this.app = express(); + LoggerRouter.route(this.app); + this.app.set('view engine', 'ejs'); + Logger.info(LOG_TAG, 'running diagnostics...'); await ConfigDiagnostics.runDiagnostics(); Logger.verbose( @@ -78,11 +83,6 @@ export class Server { return v; }, 2)); - this.app = express(); - - LoggerRouter.route(this.app); - - this.app.set('view engine', 'ejs'); /** * Session above all @@ -130,7 +130,7 @@ export class Server { Localizations.init(); this.app.use(locale(Config.Server.languages, 'en')); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); Router.route(this.app); diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 8c952526..76fcbff5 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -11,6 +11,7 @@ import { } from '../../entities/job/JobScheduleDTO'; import { ClientConfig, + ClientExtensionsConfig, ClientGPXCompressingConfig, ClientMediaConfig, ClientMetaFileConfig, @@ -1015,7 +1016,7 @@ export class ServerServiceConfig extends ClientServiceConfig { @SubConfigClass({softReadonly: true}) -export class ServerExtensionsConfig { +export class ServerExtensionsConfig extends ClientExtensionsConfig { @ConfigProperty({volatile: true}) list: string[] = []; diff --git a/src/common/config/public/ClientConfig.ts b/src/common/config/public/ClientConfig.ts index 27463c6d..777627da 100644 --- a/src/common/config/public/ClientConfig.ts +++ b/src/common/config/public/ClientConfig.ts @@ -1424,6 +1424,16 @@ export class ClientUserConfig { unAuthenticatedUserRole: UserRoles = UserRoles.Admin; } +@SubConfigClass({tags: {client: true}, softReadonly: true}) +export class ClientExtensionsConfig { + @ConfigProperty({ + tags: { + name: $localize`Enabled`, + priority: ConfigPriority.advanced, + } + }) + enabled: boolean = true; +} @SubConfigClass({tags: {client: true}, softReadonly: true}) export class ClientConfig { @@ -1496,4 +1506,13 @@ export class ClientConfig { description: $localize`This feature enables you to generate 'random photo' urls. That URL returns a photo random selected from your gallery. You can use the url with 3rd party application like random changing desktop background. Note: With the current implementation, random link also requires login.` }) RandomPhoto: ClientRandomPhotoConfig = new ClientRandomPhotoConfig(); + + + @ConfigProperty({ + tags: { + name: $localize`Extensions`, + uiIcon: 'ionCloudOutline' + } as TAGS, + }) + Extensions: ClientExtensionsConfig = new ClientExtensionsConfig(); } diff --git a/test/backend/DBTestHelper.ts b/test/backend/DBTestHelper.ts index 04454782..12f8ae0d 100644 --- a/test/backend/DBTestHelper.ts +++ b/test/backend/DBTestHelper.ts @@ -2,7 +2,7 @@ import {Config} from '../../src/common/config/private/Config'; import * as path from 'path'; import * as fs from 'fs'; import {SQLConnection} from '../../src/backend/model/database/SQLConnection'; -import {DatabaseType, LogLevel} from '../../src/common/config/private/PrivateConfig'; +import {DatabaseType} from '../../src/common/config/private/PrivateConfig'; import {ProjectPath} from '../../src/backend/ProjectPath'; import {DirectoryBaseDTO, ParentDirectoryDTO, SubDirectoryDTO} from '../../src/common/entities/DirectoryDTO'; import {ObjectManagers} from '../../src/backend/model/ObjectManagers'; @@ -111,7 +111,7 @@ export class DBTestHelper { } public static async persistTestDir(directory: DirectoryBaseDTO): Promise { - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); const connection = await SQLConnection.getConnection(); ObjectManagers.getInstance().IndexingManager.indexDirectory = () => Promise.resolve(null); @@ -122,20 +122,20 @@ export class DBTestHelper { // await im.saveToDB(subDir2); if (ObjectManagers.getInstance().IndexingManager && - ObjectManagers.getInstance().IndexingManager.IsSavingInProgress) { + ObjectManagers.getInstance().IndexingManager.IsSavingInProgress) { await ObjectManagers.getInstance().IndexingManager.SavingReady; } const gm = new GalleryManagerTest(); const dir = await gm.getParentDirFromId(connection, - (await gm.getDirIdAndTime(connection, directory.name, path.join(directory.path, path.sep))).id); + (await gm.getDirIdAndTime(connection, directory.name, path.join(directory.path, path.sep))).id); const populateDir = async (d: DirectoryBaseDTO) => { for (let i = 0; i < d.directories.length; i++) { d.directories[i] = await gm.getParentDirFromId(connection, - (await gm.getDirIdAndTime(connection, d.directories[i].name, - path.join(DiskManager.pathFromParent(d), path.sep))).id); + (await gm.getDirIdAndTime(connection, d.directories[i].name, + path.join(DiskManager.pathFromParent(d), path.sep))).id); await populateDir(d.directories[i]); } }; @@ -147,6 +147,7 @@ export class DBTestHelper { public async initDB(): Promise { await Config.load(); + Config.Extensions.enabled = false; // make all tests clean if (this.dbType === DatabaseType.sqlite) { await this.initSQLite(); } else if (this.dbType === DatabaseType.mysql) { @@ -197,7 +198,7 @@ export class DBTestHelper { const conn = await SQLConnection.getConnection(); await conn.query('CREATE DATABASE IF NOT EXISTS ' + conn.options.database); await SQLConnection.close(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); } private async clearUpMysql(): Promise { @@ -218,7 +219,7 @@ export class DBTestHelper { private async resetSQLite(): Promise { Logger.debug(LOG_TAG, 'resetting sqlite'); await this.clearUpSQLite(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); } private async clearUpSQLite(): Promise { diff --git a/test/backend/integration/routers/GalleryRouter.ts b/test/backend/integration/routers/GalleryRouter.ts index 739076e1..2c2d0c7e 100644 --- a/test/backend/integration/routers/GalleryRouter.ts +++ b/test/backend/integration/routers/GalleryRouter.ts @@ -48,7 +48,7 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => { afterEach(tearDown); it('should load gallery', async () => { - const result = await (chai.request(server.App) as SuperAgentStatic) + const result = await (chai.request(server.Server) as SuperAgentStatic) .get(Config.Server.apiPath + '/gallery/content/'); (result.should as any).have.status(200); @@ -59,10 +59,10 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => { it('should load gallery twice (to force loading form db)', async () => { Config.Indexing.reIndexingSensitivity = ReIndexingSensitivity.low; - const _ = await (chai.request(server.App) as SuperAgentStatic) + const _ = await (chai.request(server.Server) as SuperAgentStatic) .get(Config.Server.apiPath + '/gallery/content/orientation'); - const result = await (chai.request(server.App) as SuperAgentStatic) + const result = await (chai.request(server.Server) as SuperAgentStatic) .get(Config.Server.apiPath + '/gallery/content/orientation'); (result.should as any).have.status(200); @@ -80,7 +80,7 @@ describe('GalleryRouter', (sqlHelper: DBTestHelper) => { afterEach(tearDown); it('should get video without transcoding', async () => { - const result = await (chai.request(server.App) as SuperAgentStatic) + const result = await (chai.request(server.Server) as SuperAgentStatic) .get(Config.Server.apiPath + '/gallery/content/video.mp4/bestFit'); (result.should as any).have.status(200); diff --git a/test/backend/integration/routers/PublicRouter.ts b/test/backend/integration/routers/PublicRouter.ts index 91595b23..3836fd65 100644 --- a/test/backend/integration/routers/PublicRouter.ts +++ b/test/backend/integration/routers/PublicRouter.ts @@ -41,7 +41,7 @@ describe('PublicRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; @@ -71,7 +71,7 @@ describe('PublicRouter', () => { afterEach(tearDown); const fistLoad = async (srv: Server, sharingKey: string): Promise => { - return (chai.request(srv.App) as SuperAgentStatic) + return (chai.request(srv.Server) as SuperAgentStatic) .get('/share/' + sharingKey); }; diff --git a/test/backend/integration/routers/SharingRouter.ts b/test/backend/integration/routers/SharingRouter.ts index 38560742..f26160d4 100644 --- a/test/backend/integration/routers/SharingRouter.ts +++ b/test/backend/integration/routers/SharingRouter.ts @@ -42,7 +42,7 @@ describe('SharingRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; @@ -62,7 +62,7 @@ describe('SharingRouter', () => { }; const shareLogin = async (srv: Server, sharingKey: string, password?: string): Promise => { - return (chai.request(srv.App) as SuperAgentStatic) + return (chai.request(srv.Server) as SuperAgentStatic) .post(Config.Server.apiPath + '/share/login?' + QueryParams.gallery.sharingKey_query + '=' + sharingKey) .send({password}); diff --git a/test/backend/integration/routers/UserRouter.ts b/test/backend/integration/routers/UserRouter.ts index 2ffcca1d..3e0f0e80 100644 --- a/test/backend/integration/routers/UserRouter.ts +++ b/test/backend/integration/routers/UserRouter.ts @@ -42,7 +42,7 @@ describe('UserRouter', () => { server = new Server(); await server.onStarted.wait(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); await ObjectManagers.getInstance().UserManager.createUser(Utils.clone(testUser)); await SQLConnection.close(); }; @@ -62,7 +62,7 @@ describe('UserRouter', () => { }; const login = async (srv: Server): Promise => { - const result = await (chai.request(srv.App) as SuperAgentStatic) + const result = await (chai.request(srv.Server) as SuperAgentStatic) .post(Config.Server.apiPath + '/user/login') .send({ loginCredential: { @@ -87,7 +87,7 @@ describe('UserRouter', () => { }); it('it skip login', async () => { Config.Users.authenticationRequired = false; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .post(Config.Server.apiPath + '/user/login'); result.res.should.have.status(404); @@ -105,7 +105,7 @@ describe('UserRouter', () => { const loginRes = await login(server); - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me') .set('Cookie', loginRes.res.headers['set-cookie']) .set('CSRF-Token', loginRes.body.result.csrfToken); @@ -116,7 +116,7 @@ describe('UserRouter', () => { it('it should not authenticate', async () => { Config.Users.authenticationRequired = true; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me'); result.res.should.have.status(401); @@ -133,7 +133,7 @@ describe('UserRouter', () => { const loginRes = await login(server); const q: Record = {}; q[QueryParams.gallery.sharingKey_query] = sharingKey; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharingKey) .set('Cookie', loginRes.res.headers['set-cookie']) .set('CSRF-Token', loginRes.body.result.csrfToken); @@ -152,7 +152,7 @@ describe('UserRouter', () => { const q: Record = {}; q[QueryParams.gallery.sharingKey_query] = sharing.sharingKey; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharing.sharingKey); checkUserResult(result, RouteTestingHelper.getExpectedSharingUser(sharing)); @@ -167,7 +167,7 @@ describe('UserRouter', () => { const q: Record = {}; q[QueryParams.gallery.sharingKey_query] = sharing.sharingKey; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me?' + QueryParams.gallery.sharingKey_query + '=' + sharing.sharingKey); result.should.have.status(401); @@ -179,7 +179,7 @@ describe('UserRouter', () => { it('it should authenticate as guest', async () => { Config.Users.authenticationRequired = false; - const result = await chai.request(server.App) + const result = await chai.request(server.Server) .get(Config.Server.apiPath + '/user/me'); const expectedGuestUser = { diff --git a/test/backend/integration/routers/admin/SettingsRouter.ts b/test/backend/integration/routers/admin/SettingsRouter.ts index cdc98838..3fa3bea6 100644 --- a/test/backend/integration/routers/admin/SettingsRouter.ts +++ b/test/backend/integration/routers/admin/SettingsRouter.ts @@ -38,7 +38,7 @@ describe('SettingsRouter', () => { const originalSettings = await ExtensionConfigWrapper.original(); const srv = new Server(); await srv.onStarted.wait(); - const result = await chai.request(srv.App) + const result = await chai.request(srv.Server) .get(Config.Server.apiPath + '/settings'); result.res.should.have.status(200); diff --git a/test/backend/unit/middlewares/admin/SettingsMWs.ts b/test/backend/unit/middlewares/admin/SettingsMWs.ts index ef85113d..12093f64 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -22,7 +22,7 @@ describe('Settings middleware', () => { beforeEach(async () => { await ObjectManagers.reset(); await fs.promises.rm(tempDir, {recursive: true, force: true}); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }); it('should save empty enforced users settings', (done: (err?: any) => void) => { diff --git a/test/backend/unit/model/sql/AlbumManager.spec.ts b/test/backend/unit/model/sql/AlbumManager.spec.ts index 30de6d1f..9ddae4cb 100644 --- a/test/backend/unit/model/sql/AlbumManager.spec.ts +++ b/test/backend/unit/model/sql/AlbumManager.spec.ts @@ -33,7 +33,7 @@ describe('AlbumManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await sqlHelper.setUpTestGallery(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }; diff --git a/test/backend/unit/model/sql/CoverManager.spec.ts b/test/backend/unit/model/sql/CoverManager.spec.ts index 7c5cd9aa..f7fbaa77 100644 --- a/test/backend/unit/model/sql/CoverManager.spec.ts +++ b/test/backend/unit/model/sql/CoverManager.spec.ts @@ -122,7 +122,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await setUpTestGallery(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }; diff --git a/test/backend/unit/model/sql/SearchManager.spec.ts b/test/backend/unit/model/sql/SearchManager.spec.ts index 4590193c..216aa511 100644 --- a/test/backend/unit/model/sql/SearchManager.spec.ts +++ b/test/backend/unit/model/sql/SearchManager.spec.ts @@ -146,7 +146,7 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => { const setUpSqlDB = async () => { await sqlHelper.initDB(); await setUpTestGallery(); - await ObjectManagers.InitManagers(); + await ObjectManagers.getInstance().init(); }; diff --git a/test/backend/unit/model/threading/DiskManagerWorker.spec.ts b/test/backend/unit/model/threading/DiskManagerWorker.spec.ts index b0d178fd..dfef23da 100644 --- a/test/backend/unit/model/threading/DiskManagerWorker.spec.ts +++ b/test/backend/unit/model/threading/DiskManagerWorker.spec.ts @@ -15,6 +15,7 @@ describe('DiskMangerWorker', () => { Config.Database.type = DatabaseType.sqlite; Config.Faces.enabled = true; Config.Faces.keywordsToPersons = true; + Config.Extensions.enabled = false; }); @@ -24,6 +25,7 @@ describe('DiskMangerWorker', () => { const dir = await DiskManager.scanDirectory('/'); // should match the number of media (photo/video) files in the assets folder expect(dir.media.length).to.be.equals(10); + // eslint-disable-next-line @typescript-eslint/no-var-requires const expected = require(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.json')); const i = dir.media.findIndex(m => m.name === 'test image öüóőúéáű-.,.jpg'); expect(Utils.clone(dir.media[i].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg'); diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts index 459c2e04..03e472af 100644 --- a/test/backend/unit/model/threading/MetaDataLoader.spec.ts +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -18,6 +18,7 @@ describe('MetadataLoader', () => { Config.Database.type = DatabaseType.sqlite; Config.Faces.enabled = true; Config.Faces.keywordsToPersons = true; + Config.Extensions.enabled = false; }); @@ -66,7 +67,7 @@ describe('MetadataLoader', () => { }); it('jpg 2', async () => { const data = await MetadataLoader.loadPhotoMetadata( - path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.jpg')); + path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.jpg')); const expected = require(path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif2.json')); expect(Utils.clone(data)).to.be.deep.equal(expected); }); From ee8a942e5cd54594c2f33474e97704cb01998c52 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 17 Nov 2023 00:32:36 +0100 Subject: [PATCH 07/15] excluding cypress tests from mocha and refactoring node_module paths #753 --- package-lock.json | 18 +++++++++--------- package.json | 6 +++--- src/backend/middlewares/admin/SettingsMWs.ts | 2 +- .../model/extension/ExtensionConfigWrapper.ts | 4 ++-- src/common/config/private/MessagingConfig.ts | 4 ++-- src/common/config/private/PrivateConfig.ts | 3 +-- src/frontend/app/ui/admin/admin.component.ts | 2 +- .../ui/gallery/blog/blog.gallery.component.ts | 3 +-- .../search-field.gallery.component.ts | 2 +- .../app/ui/settings/settings.service.ts | 2 +- .../settings-entry/settings-entry.component.ts | 2 +- .../sorting-method.settings-entry.component.ts | 6 +++--- .../ui/settings/template/template.component.ts | 4 ++-- .../unit/middlewares/admin/SettingsMWs.ts | 2 +- 14 files changed, 29 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 912d4dac..fa93418a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pigallery2", - "version": "2.0.0-rc", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "pigallery2", - "version": "2.0.0-rc", + "version": "2.0.0", "license": "MIT", "dependencies": { "archiver": "5.3.1", @@ -89,7 +89,7 @@ "codelyzer": "6.0.2", "core-js": "3.29.0", "coveralls": "3.1.1", - "cypress": "latest", + "cypress": "13.1.0", "deep-equal-in-any-order": "2.0.5", "ejs-loader": "0.5.0", "eslint": "8.36.0", @@ -8645,9 +8645,9 @@ } }, "node_modules/cypress/node_modules/@types/node": { - "version": "16.18.48", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz", - "integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==", + "version": "16.18.61", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.61.tgz", + "integrity": "sha512-k0N7BqGhJoJzdh6MuQg1V1ragJiXTh8VUBAZTWjJ9cUq23SG0F0xavOwZbhiP4J3y20xd6jxKx+xNUhkMAi76Q==", "dev": true }, "node_modules/cypress/node_modules/ansi-styles": { @@ -30548,9 +30548,9 @@ }, "dependencies": { "@types/node": { - "version": "16.18.48", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.48.tgz", - "integrity": "sha512-mlaecDKQ7rIZrYD7iiKNdzFb6e/qD5I9U1rAhq+Fd+DWvYVs+G2kv74UFHmSOlg5+i/vF3XxuR522V4u8BqO+Q==", + "version": "16.18.61", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.61.tgz", + "integrity": "sha512-k0N7BqGhJoJzdh6MuQg1V1ragJiXTh8VUBAZTWjJ9cUq23SG0F0xavOwZbhiP4J3y20xd6jxKx+xNUhkMAi76Q==", "dev": true }, "ansi-styles": { diff --git a/package.json b/package.json index 54158f12..8d75c330 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "create-release": "gulp create-release", "build-backend": "tsc", "pretest": "tsc", - "test": "ng test && nyc mocha --recursive test", - "test-backend": "tsc && mocha --recursive test", + "test": "ng test && nyc mocha --recursive test --exclude test/cypress/**/*.js", + "test-backend": "tsc && mocha --recursive test --exclude test/cypress/**/*.js", "coverage": "nyc report --reporter=lcov", "start": "node ./src/backend/index", "run-dev": "ng build --configuration=dev", @@ -112,7 +112,7 @@ "codelyzer": "6.0.2", "core-js": "3.29.0", "coveralls": "3.1.1", - "cypress": "latest", + "cypress": "13.1.0", "deep-equal-in-any-order": "2.0.5", "ejs-loader": "0.5.0", "eslint": "8.36.0", diff --git a/src/backend/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index 6f745e03..0e7af1ca 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -3,7 +3,7 @@ import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error'; import {Logger} from '../../Logger'; import {Config} from '../../../common/config/private/Config'; import {ConfigDiagnostics} from '../../model/diagnostics/ConfigDiagnostics'; -import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; +import {ConfigClassBuilder} from 'typeconfig/node'; import {TAGS} from '../../../common/config/public/ClientConfig'; import {ObjectManagers} from '../../model/ObjectManagers'; import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper'; diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index 534b72ce..b11e5eaa 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,6 +1,6 @@ -import {IConfigClass} from '../../../../node_modules/typeconfig/common'; +import {IConfigClass} from 'typeconfig/common'; import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; -import {ConfigClassBuilder} from '../../../../node_modules/typeconfig/node'; +import {ConfigClassBuilder} from 'typeconfig/node'; import {IExtensionConfig} from './IExtension'; import {Utils} from '../../../common/Utils'; import {ObjectManagers} from '../ObjectManagers'; diff --git a/src/common/config/private/MessagingConfig.ts b/src/common/config/private/MessagingConfig.ts index 9b9cb843..067a19f7 100644 --- a/src/common/config/private/MessagingConfig.ts +++ b/src/common/config/private/MessagingConfig.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ -import {SubConfigClass} from '../../../../node_modules/typeconfig/src/decorators/class/SubConfigClass'; +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; import {ConfigPriority, TAGS} from '../public/ClientConfig'; -import {ConfigProperty} from '../../../../node_modules/typeconfig/src/decorators/property/ConfigPropoerty'; declare let $localize: (s: TemplateStringsArray) => string; @@ -96,6 +95,7 @@ export class EmailMessagingConfig { smtp?: EmailSMTPMessagingConfig = new EmailSMTPMessagingConfig(); } + @SubConfigClass({softReadonly: true}) export class MessagingConfig { @ConfigProperty({ diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 76fcbff5..24adc83b 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -26,8 +26,7 @@ import { ConfigPriority, TAGS } from '../public/ClientConfig'; -import {SubConfigClass} from 'typeconfig/src/decorators/class/SubConfigClass'; -import {ConfigProperty} from 'typeconfig/src/decorators/property/ConfigPropoerty'; +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; import {DefaultsJobs} from '../../entities/job/JobDTO'; import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/SearchQueryDTO'; import {SortByTypes} from '../../entities/SortingMethods'; diff --git a/src/frontend/app/ui/admin/admin.component.ts b/src/frontend/app/ui/admin/admin.component.ts index 64f929c7..04feb789 100644 --- a/src/frontend/app/ui/admin/admin.component.ts +++ b/src/frontend/app/ui/admin/admin.component.ts @@ -9,7 +9,7 @@ import {ConfigStyle, SettingsService} from '../settings/settings.service'; import {ConfigPriority} from '../../../../common/config/public/ClientConfig'; import {WebConfig} from '../../../../common/config/private/WebConfig'; import {ISettingsComponent} from '../settings/template/ISettingsComponent'; -import {WebConfigClassBuilder} from '../../../../../node_modules/typeconfig/src/decorators/builders/WebConfigClassBuilder'; +import {WebConfigClassBuilder} from 'typeconfig/src/decorators/builders/WebConfigClassBuilder'; import {enumToTranslatedArray} from '../EnumTranslations'; import {PiTitleService} from '../../model/pi-title.service'; diff --git a/src/frontend/app/ui/gallery/blog/blog.gallery.component.ts b/src/frontend/app/ui/gallery/blog/blog.gallery.component.ts index ea8adbea..137f709d 100644 --- a/src/frontend/app/ui/gallery/blog/blog.gallery.component.ts +++ b/src/frontend/app/ui/gallery/blog/blog.gallery.component.ts @@ -1,6 +1,5 @@ -import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {Component, EventEmitter, Input, Output,OnChanges} from '@angular/core'; import {BlogService, GroupedMarkdown} from './blog.service'; -import {OnChanges} from '../../../../../../node_modules/@angular/core'; import {map, Observable} from 'rxjs'; @Component({ diff --git a/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts index 14afd597..a1f3345a 100644 --- a/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts +++ b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts @@ -4,7 +4,7 @@ import {AutoCompleteService} from '../autocomplete.service'; import {SearchQueryDTO} from '../../../../../../common/entities/SearchQueryDTO'; import {ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator,} from '@angular/forms'; import {SearchQueryParserService} from '../search-query-parser.service'; -import {BsModalRef, BsModalService,} from '../../../../../../../node_modules/ngx-bootstrap/modal'; +import {BsModalRef, BsModalService,} from 'ngx-bootstrap/modal'; import {Utils} from '../../../../../../common/Utils'; @Component({ diff --git a/src/frontend/app/ui/settings/settings.service.ts b/src/frontend/app/ui/settings/settings.service.ts index aa27919c..4e8885f5 100644 --- a/src/frontend/app/ui/settings/settings.service.ts +++ b/src/frontend/app/ui/settings/settings.service.ts @@ -10,7 +10,7 @@ import {CookieService} from 'ngx-cookie-service'; import {DefaultsJobs} from '../../../../common/entities/job/JobDTO'; import {StatisticDTO} from '../../../../common/entities/settings/StatisticDTO'; import {ScheduledJobsService} from './scheduled-jobs.service'; -import {IWebConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass'; +import {IWebConfigClassPrivate} from 'typeconfig/src/decorators/class/IWebConfigClass'; export enum ConfigStyle { diff --git a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts index 5f2d2b4f..333add00 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts +++ b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.ts @@ -16,7 +16,7 @@ import {ConfigStyle, SettingsService} from '../../settings.service'; import {WebConfig} from '../../../../../../common/config/private/WebConfig'; import {JobScheduleConfig, UserConfig} from '../../../../../../common/config/private/PrivateConfig'; import {enumToTranslatedArray} from '../../../EnumTranslations'; -import {BsModalService} from '../../../../../../../node_modules/ngx-bootstrap/modal'; +import {BsModalService} from 'ngx-bootstrap/modal'; import {CustomSettingsEntries} from '../CustomSettingsEntries'; import {GroupByTypes, SortByTypes} from '../../../../../../common/entities/SortingMethods'; diff --git a/src/frontend/app/ui/settings/template/settings-entry/sorting-method/sorting-method.settings-entry.component.ts b/src/frontend/app/ui/settings/template/settings-entry/sorting-method/sorting-method.settings-entry.component.ts index 41b563a3..f879898e 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/sorting-method/sorting-method.settings-entry.component.ts +++ b/src/frontend/app/ui/settings/template/settings-entry/sorting-method/sorting-method.settings-entry.component.ts @@ -5,12 +5,12 @@ import { NG_VALUE_ACCESSOR, ValidationErrors, Validator -} from '../../../../../../../../node_modules/@angular/forms'; +} from '@angular/forms'; import {SortByDirectionalTypes, SortingMethod} from '../../../../../../../common/entities/SortingMethods'; import {enumToTranslatedArray} from '../../../../EnumTranslations'; import {AutoCompleteService} from '../../../../gallery/search/autocomplete.service'; -import {RouterLink} from '../../../../../../../../node_modules/@angular/router'; -import {forwardRef} from '../../../../../../../../node_modules/@angular/core'; +import {RouterLink} from '@angular/router'; +import {forwardRef} from '@angular/core'; import {Utils} from '../../../../../../../common/Utils'; @Component({ diff --git a/src/frontend/app/ui/settings/template/template.component.ts b/src/frontend/app/ui/settings/template/template.component.ts index 40f7f985..6c115e31 100644 --- a/src/frontend/app/ui/settings/template/template.component.ts +++ b/src/frontend/app/ui/settings/template/template.component.ts @@ -9,11 +9,11 @@ import {JobDTOUtils} from '../../../../../common/entities/job/JobDTO'; import {ScheduledJobsService} from '../scheduled-jobs.service'; import {UntypedFormControl} from '@angular/forms'; import {Subscription} from 'rxjs'; -import {IWebConfigClassPrivate} from '../../../../../../node_modules/typeconfig/src/decorators/class/IWebConfigClass'; +import {IWebConfigClassPrivate} from 'typeconfig/src/decorators/class/IWebConfigClass'; import {ConfigPriority, TAGS} from '../../../../../common/config/public/ClientConfig'; import {Utils} from '../../../../../common/Utils'; import {UserRoles} from '../../../../../common/entities/UserDTO'; -import {WebConfigClassBuilder} from '../../../../../../node_modules/typeconfig/src/decorators/builders/WebConfigClassBuilder'; +import {WebConfigClassBuilder} from 'typeconfig/web'; import {ErrorDTO} from '../../../../../common/entities/Error'; import {ISettingsComponent} from './ISettingsComponent'; import {CustomSettingsEntries} from './CustomSettingsEntries'; diff --git a/test/backend/unit/middlewares/admin/SettingsMWs.ts b/test/backend/unit/middlewares/admin/SettingsMWs.ts index 12093f64..72129189 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -6,10 +6,10 @@ import {SettingsMWs} from '../../../../../src/backend/middlewares/admin/Settings import {ServerUserConfig} from '../../../../../src/common/config/private/PrivateConfig'; import {Config} from '../../../../../src/common/config/private/Config'; import {UserRoles} from '../../../../../src/common/entities/UserDTO'; -import {ConfigClassBuilder} from '../../../../../node_modules/typeconfig/node'; import * as fs from 'fs'; import * as path from 'path'; import {ExtensionConfigWrapper} from '../../../../../src/backend/model/extension/ExtensionConfigWrapper'; +import {ConfigClassBuilder} from 'typeconfig/node'; declare const describe: any; From 3cf45c56b5b85ee524d14ce9cc663175951ed246 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 17 Nov 2023 22:22:13 +0100 Subject: [PATCH 08/15] Removing circular dependency from extensions #753 --- src/backend/model/extension/ExtensionDecorator.ts | 14 +++++++++++--- src/backend/model/extension/ExtensionManager.ts | 2 ++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/backend/model/extension/ExtensionDecorator.ts b/src/backend/model/extension/ExtensionDecorator.ts index ddff3c3f..a0870f49 100644 --- a/src/backend/model/extension/ExtensionDecorator.ts +++ b/src/backend/model/extension/ExtensionDecorator.ts @@ -1,7 +1,15 @@ import {IExtensionEvent, IExtensionEvents} from './IExtension'; -import {ObjectManagers} from '../ObjectManagers'; import {ExtensionEvent} from './ExtensionEvent'; +export class ExtensionDecoratorObject { + public static events: IExtensionEvents; + + static init(events: IExtensionEvents) { + this.events = events; + } + +} + export const ExtensionDecorator = (fn: (ee: IExtensionEvents) => IExtensionEvent) => { return ( target: unknown, @@ -11,11 +19,11 @@ export const ExtensionDecorator = (fn: (ee: IExtensionEvents) = const targetMethod = descriptor.value; descriptor.value = async function(...args: I) { - if (!ObjectManagers.isReady()) { + if (!ExtensionDecoratorObject.events) { return await targetMethod.apply(this, args); } - const event = fn(ObjectManagers.getInstance().ExtensionManager.events) as ExtensionEvent; + const event = fn(ExtensionDecoratorObject.events) as ExtensionEvent; const eventObj = {stopPropagation: false}; const input = await event.triggerBefore({inputs: args}, eventObj); diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 5d761e96..c98a6e69 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -10,6 +10,7 @@ import {ExtensionEvent} from './ExtensionEvent'; import * as express from 'express'; import {SQLConnection} from '../database/SQLConnection'; import {ExtensionObject} from './ExtensionObject'; +import {ExtensionDecoratorObject} from './ExtensionDecorator'; const LOG_TAG = '[ExtensionManager]'; @@ -57,6 +58,7 @@ export class ExtensionManager implements IObjectManager { } } }; + ExtensionDecoratorObject.init(this.events); } public loadExtensionsList() { From 9f5257626f2bad26e81d8ea1cc4e0c0b7b022aea Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 17 Nov 2023 23:36:14 +0100 Subject: [PATCH 09/15] Adding package.json support with dependency #753 --- package-lock.json | 23 ++++- package.json | 1 + .../model/extension/ExtensionManager.ts | 83 ++++++++++++------- .../model/extension/ExtensionObject.ts | 6 +- src/backend/model/extension/IExtension.ts | 10 +++ 5 files changed, 88 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa93418a..17e17f4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "fluent-ffmpeg": "2.1.2", "image-size": "1.0.2", "locale": "0.1.0", + "logger": "file:extensions/logger", "node-geocoder": "4.2.0", "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", @@ -131,6 +132,12 @@ "mysql": "2.18.1" } }, + "extensions/logger": { + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.21" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -15585,8 +15592,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash-es": { "version": "4.17.21", @@ -15843,6 +15849,10 @@ "node": ">=8.0" } }, + "node_modules/logger": { + "resolved": "extensions/logger", + "link": true + }, "node_modules/loupe": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", @@ -35932,8 +35942,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash-es": { "version": "4.17.21", @@ -36137,6 +36146,12 @@ "streamroller": "^3.0.6" } }, + "logger": { + "version": "file:extensions/logger", + "requires": { + "lodash": "4.17.21" + } + }, "loupe": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", diff --git a/package.json b/package.json index 8d75c330..e47b1596 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "fluent-ffmpeg": "2.1.2", "image-size": "1.0.2", "locale": "0.1.0", + "logger": "file:extensions/logger", "node-geocoder": "4.2.0", "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index c98a6e69..1d9ee898 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -11,6 +11,9 @@ import * as express from 'express'; import {SQLConnection} from '../database/SQLConnection'; import {ExtensionObject} from './ExtensionObject'; import {ExtensionDecoratorObject} from './ExtensionDecorator'; +import * as util from 'util'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const exec = util.promisify(require('child_process').exec); const LOG_TAG = '[ExtensionManager]'; @@ -68,43 +71,60 @@ export class ExtensionManager implements IObjectManager { } Config.Extensions.list = fs - .readdirSync(ProjectPath.ExtensionFolder) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); Config.Extensions.list.sort(); Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list)); } - private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise) { - for (let i = 0; i < Config.Extensions.list.length; ++i) { - const extName = Config.Extensions.list[i]; - const extPath = path.join(ProjectPath.ExtensionFolder, extName); - const serverExt = path.join(extPath, 'server.js'); - if (!fs.existsSync(serverExt)) { - Logger.silly(LOG_TAG, `Skipping ${extName} server initiation. server.js does not exists`); - continue; + private createUniqueExtensionObject(name: string, folder: string): IExtensionObject { + let id = name; + if (this.extObjects[id]) { + let i = 0; + while (this.extObjects[`${name}_${++i}`]) { /* empty */ } - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ext = require(serverExt); - await fn(ext, extName); + id = `${name}_${++i}`; } - } - - private createExtensionObject(name: string): IExtensionObject { - if (!this.extObjects[name]) { - this.extObjects[name] = new ExtensionObject(name, this.router, this.events); + if (!this.extObjects[id]) { + this.extObjects[id] = new ExtensionObject(id, name, folder, this.router, this.events); } - return this.extObjects[name]; + return this.extObjects[id]; } private async initExtensions() { - await this.callServerFN(async (ext, extName) => { - if (typeof ext?.init === 'function') { - Logger.debug(LOG_TAG, 'Running init on extension: ' + extName); - await ext?.init(this.createExtensionObject(extName)); + + for (let i = 0; i < Config.Extensions.list.length; ++i) { + const extFolder = Config.Extensions.list[i]; + let extName = extFolder; + const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); + const serverExtPath = path.join(extPath, 'server.js'); + const packageJsonPath = path.join(extPath, 'package.json'); + if (!fs.existsSync(serverExtPath)) { + Logger.silly(LOG_TAG, `Skipping ${extFolder} server initiation. server.js does not exists`); + continue; } - }); + + if (fs.existsSync(packageJsonPath)) { + Logger.silly(LOG_TAG, `Running: "npm install --omit=dev" in ${extPath}`); + await exec('npm install --omit=dev' ,{ + cwd:extPath + }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require(packageJsonPath); + if (pkg.name) { + extName = pkg.name; + } + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ext = require(serverExtPath); + if (typeof ext?.init === 'function') { + Logger.debug(LOG_TAG, 'Running init on extension: ' + extFolder); + await ext?.init(this.createUniqueExtensionObject(extName, extPath)); + } + } if (Config.Extensions.cleanUpUnusedTables) { // Clean up tables after all Extension was initialized. await SQLConnection.removeUnusedTables(); @@ -112,12 +132,15 @@ export class ExtensionManager implements IObjectManager { } private async cleanUpExtensions() { - await this.callServerFN(async (ext, extName) => { + for (const extObj of Object.values(this.extObjects)) { + const serverExt = path.join(extObj.folder, 'server.js'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const ext = require(serverExt); if (typeof ext?.cleanUp === 'function') { - Logger.debug(LOG_TAG, 'Running Init on extension:' + extName); - await ext?.cleanUp(this.createExtensionObject(extName)); + Logger.debug(LOG_TAG, 'Running Init on extension:' + extObj.extensionName); + await ext?.cleanUp(ext); } - }); + } } diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index f21e28ae..dcc35ed1 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -17,7 +17,11 @@ export class ExtensionObject implements IExtensionObject { public readonly events; public readonly RESTApi; - constructor(public readonly extensionId: string, extensionRouter: express.Router, events: IExtensionEvents) { + constructor(public readonly extensionId: string, + public readonly extensionName: string, + public readonly folder: string, + extensionRouter: express.Router, + events: IExtensionEvents) { const logger = createLoggerWrapper(`[Extension][${extensionId}]`); this._app = new ExtensionApp(); this.config = new ExtensionConfig(extensionId); diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index 3375caaf..038cdf7a 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -117,6 +117,16 @@ export interface IExtensionConfig { } export interface IExtensionObject { + /** + * ID of the extension that is internally used. By default the name and ID matches if there is no collision. + */ + extensionId: string, + + /** + * Name of the extension + */ + extensionName: string, + /** * Inner functionality of the app. Use this with caution. * If you want to go deeper than the standard exposed APIs, you can try doing so here. From 7208a3b4fe6dd21b2c074c50e1d0580f0a7a2a1a Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 18 Nov 2023 14:43:22 +0100 Subject: [PATCH 10/15] Refactoring Job config and exposing it to messenger #753 --- .../model/jobs/jobs/AlbumCoverFillingJob.ts | 11 ++++++----- .../model/jobs/jobs/AlbumCoverResetJob.ts | 5 +++-- src/backend/model/jobs/jobs/AlbumResetJob.ts | 5 +++-- src/backend/model/jobs/jobs/FileJob.ts | 4 ++-- src/backend/model/jobs/jobs/GalleryResetJob.ts | 5 +++-- src/backend/model/jobs/jobs/IndexingJob.ts | 5 +++-- src/backend/model/jobs/jobs/Job.ts | 5 +++-- .../model/jobs/jobs/TempFolderCleaningJob.ts | 5 +++-- src/backend/model/jobs/jobs/TopPickSendJob.ts | 3 ++- src/common/entities/DynamicConfig.ts | 17 +++++++++++++++++ src/common/entities/job/JobDTO.ts | 10 ++-------- 11 files changed, 47 insertions(+), 28 deletions(-) create mode 100644 src/common/entities/DynamicConfig.ts diff --git a/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts b/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts index 89ab10f5..62a3411d 100644 --- a/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts +++ b/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts @@ -1,10 +1,11 @@ import {ObjectManagers} from '../../ObjectManagers'; -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultsJobs} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; export class AlbumCoverFillingJob extends Job { public readonly Name = DefaultsJobs[DefaultsJobs['Album Cover Filling']]; - public readonly ConfigTemplate: ConfigTemplateEntry[] = null; + public readonly ConfigTemplate: DynamicConfig[] = null; directoryToSetCover: { id: number; name: string; path: string }[] = null; status: 'Persons' | 'Albums' | 'Directory' = 'Persons'; @@ -20,7 +21,7 @@ export class AlbumCoverFillingJob extends Job { if (!this.directoryToSetCover) { this.Progress.log('Loading Directories to process'); this.directoryToSetCover = - await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers(); + await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers(); this.Progress.Left = this.directoryToSetCover.length + 2; return true; } @@ -57,7 +58,7 @@ export class AlbumCoverFillingJob extends Job { private async stepDirectoryCover(): Promise { if (this.directoryToSetCover.length === 0) { this.directoryToSetCover = - await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers(); + await ObjectManagers.getInstance().CoverManager.getPartialDirsWithoutCovers(); // double check if there is really no more if (this.directoryToSetCover.length > 0) { return true; // continue @@ -70,7 +71,7 @@ export class AlbumCoverFillingJob extends Job { this.Progress.Left = this.directoryToSetCover.length; await ObjectManagers.getInstance().CoverManager.setAndGetCoverForDirectory( - directory + directory ); this.Progress.Processed++; return true; diff --git a/src/backend/model/jobs/jobs/AlbumCoverResetJob.ts b/src/backend/model/jobs/jobs/AlbumCoverResetJob.ts index 32ee08eb..07121827 100644 --- a/src/backend/model/jobs/jobs/AlbumCoverResetJob.ts +++ b/src/backend/model/jobs/jobs/AlbumCoverResetJob.ts @@ -1,10 +1,11 @@ import {ObjectManagers} from '../../ObjectManagers'; -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultsJobs} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; export class AlbumCoverRestJob extends Job { public readonly Name = DefaultsJobs[DefaultsJobs['Album Cover Reset']]; - public readonly ConfigTemplate: ConfigTemplateEntry[] = null; + public readonly ConfigTemplate: DynamicConfig[] = null; protected readonly IsInstant = true; public get Supported(): boolean { diff --git a/src/backend/model/jobs/jobs/AlbumResetJob.ts b/src/backend/model/jobs/jobs/AlbumResetJob.ts index 93967743..edae7ef9 100644 --- a/src/backend/model/jobs/jobs/AlbumResetJob.ts +++ b/src/backend/model/jobs/jobs/AlbumResetJob.ts @@ -1,10 +1,11 @@ import {ObjectManagers} from '../../ObjectManagers'; -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; export class AlbumRestJob extends Job { public readonly Name = DefaultsJobs[DefaultsJobs['Album Reset']]; - public readonly ConfigTemplate: ConfigTemplateEntry[] = null; + public readonly ConfigTemplate: DynamicConfig[] = null; protected readonly IsInstant = true; public get Supported(): boolean { diff --git a/src/backend/model/jobs/jobs/FileJob.ts b/src/backend/model/jobs/jobs/FileJob.ts index f5295dbc..2159973b 100644 --- a/src/backend/model/jobs/jobs/FileJob.ts +++ b/src/backend/model/jobs/jobs/FileJob.ts @@ -1,4 +1,3 @@ -import {ConfigTemplateEntry} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; import * as path from 'path'; import {Logger} from '../../../Logger'; @@ -13,6 +12,7 @@ import {ProjectPath} from '../../../ProjectPath'; import {FileEntity} from '../../database/enitites/FileEntity'; import {DirectoryBaseDTO, DirectoryDTOUtils} from '../../../../common/entities/DirectoryDTO'; import {DirectoryScanSettings, DiskManager} from '../../fileaccess/DiskManager'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; const LOG_TAG = '[FileJob]'; @@ -20,7 +20,7 @@ const LOG_TAG = '[FileJob]'; * Abstract class for thumbnail creation, file deleting etc. */ export abstract class FileJob extends Job { - public readonly ConfigTemplate: ConfigTemplateEntry[] = []; + public readonly ConfigTemplate: DynamicConfig[] = []; directoryQueue: string[] = []; fileQueue: string[] = []; DBProcessing = { diff --git a/src/backend/model/jobs/jobs/GalleryResetJob.ts b/src/backend/model/jobs/jobs/GalleryResetJob.ts index 00fa74e4..ee3c162f 100644 --- a/src/backend/model/jobs/jobs/GalleryResetJob.ts +++ b/src/backend/model/jobs/jobs/GalleryResetJob.ts @@ -1,10 +1,11 @@ import {ObjectManagers} from '../../ObjectManagers'; -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultsJobs} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; export class GalleryRestJob extends Job { public readonly Name = DefaultsJobs[DefaultsJobs['Gallery Reset']]; - public readonly ConfigTemplate: ConfigTemplateEntry[] = null; + public readonly ConfigTemplate: DynamicConfig[] = null; protected readonly IsInstant = true; public get Supported(): boolean { diff --git a/src/backend/model/jobs/jobs/IndexingJob.ts b/src/backend/model/jobs/jobs/IndexingJob.ts index 709d8271..65973e85 100644 --- a/src/backend/model/jobs/jobs/IndexingJob.ts +++ b/src/backend/model/jobs/jobs/IndexingJob.ts @@ -2,7 +2,7 @@ import {ObjectManagers} from '../../ObjectManagers'; import * as path from 'path'; import * as fs from 'fs'; import {Job} from './Job'; -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultsJobs} from '../../../../common/entities/job/JobDTO'; import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO'; import {ProjectPath} from '../../../ProjectPath'; import {backendTexts} from '../../../../common/BackendTexts'; @@ -10,6 +10,7 @@ import {ParentDirectoryDTO} from '../../../../common/entities/DirectoryDTO'; import {Logger} from '../../../Logger'; import {FileDTO} from '../../../../common/entities/FileDTO'; import {DiskManager} from '../../fileaccess/DiskManager'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; const LOG_TAG = '[IndexingJob]'; @@ -18,7 +19,7 @@ export class IndexingJob< > extends Job { public readonly Name = DefaultsJobs[DefaultsJobs.Indexing]; directoriesToIndex: string[] = []; - public readonly ConfigTemplate: ConfigTemplateEntry[] = [ + public readonly ConfigTemplate: DynamicConfig[] = [ { id: 'indexChangesOnly', type: 'boolean', diff --git a/src/backend/model/jobs/jobs/Job.ts b/src/backend/model/jobs/jobs/Job.ts index c17639cc..36a7a0cb 100644 --- a/src/backend/model/jobs/jobs/Job.ts +++ b/src/backend/model/jobs/jobs/Job.ts @@ -1,9 +1,10 @@ import {Logger} from '../../../Logger'; import {IJob} from './IJob'; -import {ConfigTemplateEntry, JobDTO, JobDTOUtils,} from '../../../../common/entities/job/JobDTO'; +import {JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO'; import {JobProgress} from './JobProgress'; import {IJobListener} from './IJobListener'; import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; declare const process: { nextTick: (_: unknown) => void }; declare const global: { gc: () => void }; @@ -27,7 +28,7 @@ export abstract class Job = Record { public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']]; public readonly Supported: boolean = true; - public readonly ConfigTemplate: ConfigTemplateEntry[] = [ + public readonly ConfigTemplate: DynamicConfig[] = [ { id: 'mediaPick', type: 'MediaPickDTO-array', diff --git a/src/common/entities/DynamicConfig.ts b/src/common/entities/DynamicConfig.ts new file mode 100644 index 00000000..becfc97b --- /dev/null +++ b/src/common/entities/DynamicConfig.ts @@ -0,0 +1,17 @@ +import {backendText} from '../BackendTexts'; +import {fieldType} from './job/JobDTO'; + +/** + * Dynamic configs are not part of the typeconfig maintained config. + * Pigallery uses them to dynamically define configuration + * on the serverside so the client can parse and render it. + * It is mostly used for configuring jobs + */ +export interface DynamicConfig { + id: string; + // Use a predefined and localized backend text id or explicitly define the text + name: backendText | string; + description: backendText | string; + type: fieldType; + defaultValue: unknown; +} diff --git a/src/common/entities/job/JobDTO.ts b/src/common/entities/job/JobDTO.ts index ac98c2b6..3001a80f 100644 --- a/src/common/entities/job/JobDTO.ts +++ b/src/common/entities/job/JobDTO.ts @@ -1,4 +1,5 @@ import {backendText} from '../../BackendTexts'; +import {DynamicConfig} from '../DynamicConfig'; export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array'; @@ -17,17 +18,10 @@ export enum DefaultsJobs { 'Top Pick Sending' = 12 } -export interface ConfigTemplateEntry { - id: string; - name: backendText; - description: backendText; - type: fieldType; - defaultValue: any; -} export interface JobDTO { Name: string; - ConfigTemplate: ConfigTemplateEntry[]; + ConfigTemplate: DynamicConfig[]; } export const JobDTOUtils = { From 50b8f7a81d0700f5fb10ec51478e3e6432216159 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 18 Nov 2023 16:26:42 +0100 Subject: [PATCH 11/15] Refactoring messenger to prepare extension support #753 --- src/backend/middlewares/admin/AdminMWs.ts | 194 ++++++++++-------- src/backend/model/jobs/JobManager.ts | 8 +- src/backend/model/jobs/JobRepository.ts | 6 +- src/backend/model/jobs/jobs/IJob.ts | 2 +- src/backend/model/jobs/jobs/IJobListener.ts | 2 +- .../model/jobs/jobs/ThumbnailGenerationJob.ts | 2 +- src/backend/model/jobs/jobs/TopPickSendJob.ts | 102 ++++----- .../EmailMessenger.ts} | 69 ++++--- src/backend/model/messenger/Messenger.ts | 50 +++++ .../model/messenger/MessengerRepository.ts | 34 +++ .../model/messenger/StdoutMessenger.ts | 17 ++ src/backend/routes/admin/AdminRouter.ts | 10 + src/common/BackendTexts.ts | 4 +- src/common/entities/DynamicConfig.ts | 6 +- src/common/entities/job/JobDTO.ts | 9 +- src/frontend/app/model/backendtext.service.ts | 5 +- .../app/ui/settings/scheduled-jobs.service.ts | 13 +- .../settings/workflow/workflow.component.html | 13 ++ .../settings/workflow/workflow.component.ts | 5 +- 19 files changed, 370 insertions(+), 181 deletions(-) rename src/backend/model/{mediamessengers/EmailMediaMessenger.ts => messenger/EmailMessenger.ts} (57%) create mode 100644 src/backend/model/messenger/Messenger.ts create mode 100644 src/backend/model/messenger/MessengerRepository.ts create mode 100644 src/backend/model/messenger/StdoutMessenger.ts diff --git a/src/backend/middlewares/admin/AdminMWs.ts b/src/backend/middlewares/admin/AdminMWs.ts index e7bc288a..68fc7f90 100644 --- a/src/backend/middlewares/admin/AdminMWs.ts +++ b/src/backend/middlewares/admin/AdminMWs.ts @@ -2,18 +2,19 @@ import {NextFunction, Request, Response} from 'express'; import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error'; import {ObjectManagers} from '../../model/ObjectManagers'; import {StatisticDTO} from '../../../common/entities/settings/StatisticDTO'; +import {MessengerRepository} from '../../model/messenger/MessengerRepository'; export class AdminMWs { public static async loadStatistic( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { const galleryManager = ObjectManagers.getInstance() - .GalleryManager; + .GalleryManager; const personManager = ObjectManagers.getInstance() - .PersonManager; + .PersonManager; try { req.resultPipe = { directories: await galleryManager.countDirectories(), @@ -26,87 +27,87 @@ export class AdminMWs { } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.GENERAL_ERROR, - 'Error while getting statistic: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.GENERAL_ERROR, + 'Error while getting statistic: ' + err.toString(), + err + ) ); } return next( - new ErrorDTO( - ErrorCodes.GENERAL_ERROR, - 'Error while getting statistic', - err - ) + new ErrorDTO( + ErrorCodes.GENERAL_ERROR, + 'Error while getting statistic', + err + ) ); } } public static async getDuplicates( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { try { req.resultPipe = await ObjectManagers.getInstance() - .GalleryManager.getPossibleDuplicates(); + .GalleryManager.getPossibleDuplicates(); return next(); } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.GENERAL_ERROR, - 'Error while getting duplicates: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.GENERAL_ERROR, + 'Error while getting duplicates: ' + err.toString(), + err + ) ); } return next( - new ErrorDTO( - ErrorCodes.GENERAL_ERROR, - 'Error while getting duplicates', - err - ) + new ErrorDTO( + ErrorCodes.GENERAL_ERROR, + 'Error while getting duplicates', + err + ) ); } } public static async startJob( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { try { const id = req.params['id']; - const JobConfig: unknown = req.body.config; + const JobConfig: Record = req.body.config; const soloRun: boolean = req.body.soloRun; const allowParallelRun: boolean = req.body.allowParallelRun; await ObjectManagers.getInstance().JobManager.run( - id, - JobConfig, - soloRun, - allowParallelRun + id, + JobConfig, + soloRun, + allowParallelRun ); req.resultPipe = 'ok'; return next(); } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + err.toString(), + err + ) ); } return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + JSON.stringify(err, null, ' '), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + JSON.stringify(err, null, ' '), + err + ) ); } } @@ -120,56 +121,85 @@ export class AdminMWs { } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + err.toString(), + err + ) ); } return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + JSON.stringify(err, null, ' '), + err + ) + ); + } + } + + + public static getAvailableMessengers( + req: Request, + res: Response, + next: NextFunction + ): void { + try { + req.resultPipe = MessengerRepository.Instance.getAll().map(msgr => msgr.Name); + return next(); + } catch (err) { + if (err instanceof Error) { + return next( new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + JSON.stringify(err, null, ' '), - err + ErrorCodes.JOB_ERROR, + 'Messenger error: ' + err.toString(), + err ) + ); + } + return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Messenger error: ' + JSON.stringify(err, null, ' '), + err + ) ); } } public static getAvailableJobs( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): void { try { req.resultPipe = - ObjectManagers.getInstance().JobManager.getAvailableJobs(); + ObjectManagers.getInstance().JobManager.getAvailableJobs(); return next(); } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + err.toString(), + err + ) ); } return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + JSON.stringify(err, null, ' '), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + JSON.stringify(err, null, ' '), + err + ) ); } } public static getJobProgresses( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): void { try { req.resultPipe = ObjectManagers.getInstance().JobManager.getProgresses(); @@ -177,19 +207,19 @@ export class AdminMWs { } catch (err) { if (err instanceof Error) { return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + err.toString(), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + err.toString(), + err + ) ); } return next( - new ErrorDTO( - ErrorCodes.JOB_ERROR, - 'Job error: ' + JSON.stringify(err, null, ' '), - err - ) + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Job error: ' + JSON.stringify(err, null, ' '), + err + ) ); } } diff --git a/src/backend/model/jobs/JobManager.ts b/src/backend/model/jobs/JobManager.ts index dedc8db1..fc0f7a87 100644 --- a/src/backend/model/jobs/JobManager.ts +++ b/src/backend/model/jobs/JobManager.ts @@ -50,7 +50,7 @@ export class JobManager implements IJobListener, IObjectManager { return prg; } - public async run( + public async run>( jobName: string, config: T, soloRun: boolean, @@ -86,7 +86,7 @@ export class JobManager implements IJobListener, IObjectManager { }; onJobFinished = async ( - job: IJob, + job: IJob, state: JobProgressStates, soloRun: boolean ): Promise => { @@ -121,7 +121,7 @@ export class JobManager implements IJobListener, IObjectManager { } }; - getAvailableJobs(): IJob[] { + getAvailableJobs(): IJob[] { return JobRepository.Instance.getAvailableJobs(); } @@ -144,7 +144,7 @@ export class JobManager implements IJobListener, IObjectManager { Config.Jobs.scheduled.forEach((s): void => this.runSchedule(s)); } - protected findJob(jobName: string): IJob { + protected findJob(jobName: string): IJob { return this.getAvailableJobs().find((t): boolean => t.Name === jobName); } diff --git a/src/backend/model/jobs/JobRepository.ts b/src/backend/model/jobs/JobRepository.ts index f195bf21..92a10bcd 100644 --- a/src/backend/model/jobs/JobRepository.ts +++ b/src/backend/model/jobs/JobRepository.ts @@ -14,7 +14,7 @@ import {AlbumCoverRestJob} from './jobs/AlbumCoverResetJob'; export class JobRepository { private static instance: JobRepository = null; - availableJobs: { [key: string]: IJob } = {}; + availableJobs: { [key: string]: IJob } = {}; public static get Instance(): JobRepository { if (JobRepository.instance == null) { @@ -23,11 +23,11 @@ export class JobRepository { return JobRepository.instance; } - getAvailableJobs(): IJob[] { + getAvailableJobs(): IJob[] { return Object.values(this.availableJobs).filter((t) => t.Supported); } - register(job: IJob): void { + register(job: IJob): void { if (typeof this.availableJobs[job.Name] !== 'undefined') { throw new Error('Job already exist:' + job.Name); } diff --git a/src/backend/model/jobs/jobs/IJob.ts b/src/backend/model/jobs/jobs/IJob.ts index acd84d5a..4c08d78e 100644 --- a/src/backend/model/jobs/jobs/IJob.ts +++ b/src/backend/model/jobs/jobs/IJob.ts @@ -2,7 +2,7 @@ import {JobDTO} from '../../../../common/entities/job/JobDTO'; import {JobProgress} from './JobProgress'; import {IJobListener} from './IJobListener'; -export interface IJob extends JobDTO { +export interface IJob = Record> extends JobDTO { Name: string; Supported: boolean; Progress: JobProgress; diff --git a/src/backend/model/jobs/jobs/IJobListener.ts b/src/backend/model/jobs/jobs/IJobListener.ts index bf1f1b1f..73b7f87f 100644 --- a/src/backend/model/jobs/jobs/IJobListener.ts +++ b/src/backend/model/jobs/jobs/IJobListener.ts @@ -4,7 +4,7 @@ import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO' export interface IJobListener { onJobFinished( - job: IJob, + job: IJob, state: JobProgressStates, soloRun: boolean ): void; diff --git a/src/backend/model/jobs/jobs/ThumbnailGenerationJob.ts b/src/backend/model/jobs/jobs/ThumbnailGenerationJob.ts index f6132039..e8a25f84 100644 --- a/src/backend/model/jobs/jobs/ThumbnailGenerationJob.ts +++ b/src/backend/model/jobs/jobs/ThumbnailGenerationJob.ts @@ -35,7 +35,7 @@ export class ThumbnailGenerationJob extends FileJob<{ ): Promise { if (!config || !config.sizes || !Array.isArray(config.sizes) || config.sizes.length === 0) { config = config || {}; - config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue; + config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue as number[]; } for (const item of config.sizes) { if (Config.Media.Thumbnail.thumbnailSizes.indexOf(item) === -1) { diff --git a/src/backend/model/jobs/jobs/TopPickSendJob.ts b/src/backend/model/jobs/jobs/TopPickSendJob.ts index b893feae..2ba0450c 100644 --- a/src/backend/model/jobs/jobs/TopPickSendJob.ts +++ b/src/backend/model/jobs/jobs/TopPickSendJob.ts @@ -1,64 +1,67 @@ -import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; +import {DefaultMessengers, DefaultsJobs,} from '../../../../common/entities/job/JobDTO'; import {Job} from './Job'; import {backendTexts} from '../../../../common/BackendTexts'; import {SortByTypes} from '../../../../common/entities/SortingMethods'; import {DatePatternFrequency, DatePatternSearch, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO'; import {ObjectManagers} from '../../ObjectManagers'; import {PhotoEntity} from '../../database/enitites/PhotoEntity'; -import {EmailMediaMessenger} from '../../mediamessengers/EmailMediaMessenger'; import {MediaPickDTO} from '../../../../common/entities/MediaPickDTO'; import {MediaDTOUtils} from '../../../../common/entities/MediaDTO'; import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; +import {MessengerRepository} from '../../messenger/MessengerRepository'; +import {Utils} from '../../../../common/Utils'; export class TopPickSendJob extends Job<{ mediaPick: MediaPickDTO[], + messenger: string, emailTo: string, - emailFrom: string, emailSubject: string, emailText: string, }> { public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']]; public readonly Supported: boolean = true; - public readonly ConfigTemplate: DynamicConfig[] = [ - { - id: 'mediaPick', - type: 'MediaPickDTO-array', - name: backendTexts.mediaPick.name, - description: backendTexts.mediaPick.description, - defaultValue: [{ - searchQuery: { - type: SearchQueryTypes.date_pattern, - daysLength: 7, - frequency: DatePatternFrequency.every_year - } as DatePatternSearch, - sortBy: [{method: SortByTypes.Rating, ascending: false}, - {method: SortByTypes.PersonCount, ascending: false}], - pick: 5 - }] as MediaPickDTO[], - }, { - id: 'emailTo', - type: 'string-array', - name: backendTexts.emailTo.name, - description: backendTexts.emailTo.description, - defaultValue: [], - }, { - id: 'emailSubject', - type: 'string', - name: backendTexts.emailSubject.name, - description: backendTexts.emailSubject.description, - defaultValue: 'Latest photos for you', - }, { - id: 'emailText', - type: 'string', - name: backendTexts.emailText.name, - description: backendTexts.emailText.description, - defaultValue: 'I hand picked these photos just for you:', - }, - ]; + public readonly ConfigTemplate: DynamicConfig[]; private status: 'Listing' | 'Sending' = 'Listing'; private mediaList: PhotoEntity[] = []; + constructor() { + super(); + this.ConfigTemplate = [ + { + id: 'mediaPick', + type: 'MediaPickDTO-array', + name: backendTexts.mediaPick.name, + description: backendTexts.mediaPick.description, + defaultValue: [{ + searchQuery: { + type: SearchQueryTypes.date_pattern, + daysLength: 7, + frequency: DatePatternFrequency.every_year + } as DatePatternSearch, + sortBy: [{method: SortByTypes.Rating, ascending: false}, + {method: SortByTypes.PersonCount, ascending: false}], + pick: 5 + }] as MediaPickDTO[], + }, { + id: 'messenger', + type: 'messenger', + name: backendTexts.messenger.name, + description: backendTexts.messenger.description, + defaultValue: DefaultMessengers[DefaultMessengers.Email] + } + ]; + + // add all messenger's config to the config template + MessengerRepository.Instance.getAll() + .forEach(msgr => Utils.clone(msgr.ConfigTemplate) + .forEach(ct => { + const c = Utils.clone(ct); + c.validIf = {configFiled: 'messenger', equalsValue: msgr.Name}; + this.ConfigTemplate.push(c); + })); + } + protected async init(): Promise { this.status = 'Listing'; @@ -86,15 +89,15 @@ export class TopPickSendJob extends Job<{ this.mediaList = []; for (let i = 0; i < this.config.mediaPick.length; ++i) { const media = await ObjectManagers.getInstance().SearchManager - .getNMedia(this.config.mediaPick[i].searchQuery, this.config.mediaPick[i].sortBy, this.config.mediaPick[i].pick); + .getNMedia(this.config.mediaPick[i].searchQuery, this.config.mediaPick[i].sortBy, this.config.mediaPick[i].pick); this.Progress.log('Find ' + media.length + ' photos and videos from ' + (i + 1) + '. load'); this.mediaList = this.mediaList.concat(media); } // make the list unique this.mediaList = this.mediaList - .filter((value, index, arr) => - arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index); + .filter((value, index, arr) => + arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index); this.Progress.Processed++; // console.log(this.mediaList); @@ -103,17 +106,16 @@ export class TopPickSendJob extends Job<{ private async stepSending(): Promise { if (this.mediaList.length <= 0) { - this.Progress.log('No photos found skipping e-mail sending.'); + this.Progress.log('No photos found skipping sending.'); this.Progress.Skipped++; return false; } - this.Progress.log('Sending emails of ' + this.mediaList.length + ' photos.'); - const messenger = new EmailMediaMessenger(); - await messenger.sendMedia({ - to: this.config.emailTo, - subject: this.config.emailSubject, - text: this.config.emailText - }, this.mediaList); + const msgr = MessengerRepository.Instance.get(this.config.messenger); + if (!msgr) { + throw new Error('Can\t find "' + this.config.messenger + '" messenger.'); + } + this.Progress.log('Sending ' + this.mediaList.length + ' photos.'); + await msgr.send(this.config, this.mediaList); this.Progress.Processed++; return false; } diff --git a/src/backend/model/mediamessengers/EmailMediaMessenger.ts b/src/backend/model/messenger/EmailMessenger.ts similarity index 57% rename from src/backend/model/mediamessengers/EmailMediaMessenger.ts rename to src/backend/model/messenger/EmailMessenger.ts index a4108449..c412c55f 100644 --- a/src/backend/model/mediamessengers/EmailMediaMessenger.ts +++ b/src/backend/model/messenger/EmailMessenger.ts @@ -1,18 +1,40 @@ import {createTransport, Transporter} from 'nodemailer'; -import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO'; import {Config} from '../../../common/config/private/Config'; -import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing'; -import {ThumbnailSourceType} from '../fileaccess/PhotoWorker'; -import {ProjectPath} from '../../ProjectPath'; -import * as path from 'path'; import {PhotoMetadata} from '../../../common/entities/PhotoDTO'; -import {Utils} from '../../../common/Utils'; -import {QueryParams} from '../../../common/QueryParams'; +import {MediaDTOWithThPath, Messenger} from './Messenger'; +import {backendTexts} from '../../../common/BackendTexts'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; +import {DefaultMessengers} from '../../../common/entities/job/JobDTO'; -export class EmailMediaMessenger { +export class EmailMessenger extends Messenger<{ + emailTo: string, + emailSubject: string, + emailText: string, +}> { + public readonly Name = DefaultMessengers[DefaultMessengers.Email]; + public readonly ConfigTemplate: DynamicConfig[] = [{ + id: 'emailTo', + type: 'string-array', + name: backendTexts.emailTo.name, + description: backendTexts.emailTo.description, + defaultValue: [], + }, { + id: 'emailSubject', + type: 'string', + name: backendTexts.emailSubject.name, + description: backendTexts.emailSubject.description, + defaultValue: 'Latest photos for you', + }, { + id: 'emailText', + type: 'string', + name: backendTexts.emailText.name, + description: backendTexts.emailText.description, + defaultValue: 'I hand picked these photos just for you:', + }]; transporter: Transporter; constructor() { + super(); this.transporter = createTransport({ host: Config.Messaging.Email.smtp.host, port: Config.Messaging.Email.smtp.port, @@ -25,24 +47,16 @@ export class EmailMediaMessenger { }); } - private async getThumbnail(m: MediaDTO) { - return await PhotoProcessing.generateThumbnail( - path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name), - Config.Media.Thumbnail.thumbnailSizes[0], - MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video, - false - ); - } - public async sendMedia(mailSettings: { - to: string, - subject: string, - text: string - }, media: MediaDTO[]) { + protected async sendMedia(mailSettings: { + emailTo: string, + emailSubject: string, + emailText: string + }, media: MediaDTOWithThPath[]) { const attachments = []; const htmlStart = '

' + Config.Server.applicationTitle + '

\n' + - '

' + mailSettings.text + '

\n' + + '

' + mailSettings.emailText + '

\n' + '\n' + ' \n'; const htmlEnd = ' \n' + @@ -51,9 +65,6 @@ export class EmailMediaMessenger { let htmlMiddle = ''; const numberOfColumns = media.length >= 6 ? 3 : 2; for (let i = 0; i < media.length; ++i) { - const thPath = await this.getThumbnail(media[i]); - const linkUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) + - '?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name); const location = (media[i].metadata as PhotoMetadata).positionData?.country ? (media[i].metadata as PhotoMetadata).positionData?.country : ((media[i].metadata as PhotoMetadata).positionData?.city ? @@ -61,14 +72,14 @@ export class EmailMediaMessenger { const caption = (new Date(media[i].metadata.creationDate)).getFullYear() + (location ? ', ' + location : ''); attachments.push({ filename: media[i].name, - path: thPath, + path: media[i].thumbnailPath, cid: 'img' + i }); if (i % numberOfColumns == 0) { htmlMiddle += ''; } htmlMiddle += '\n'; @@ -79,8 +90,8 @@ export class EmailMediaMessenger { return await this.transporter.sendMail({ from: Config.Messaging.Email.emailFrom, - to: mailSettings.to, - subject: mailSettings.subject, + to: mailSettings.emailTo, + subject: mailSettings.emailSubject, html: htmlStart + htmlMiddle + htmlEnd, attachments: attachments }); diff --git a/src/backend/model/messenger/Messenger.ts b/src/backend/model/messenger/Messenger.ts new file mode 100644 index 00000000..3d2cf338 --- /dev/null +++ b/src/backend/model/messenger/Messenger.ts @@ -0,0 +1,50 @@ +import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO'; +import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing'; +import {ProjectPath} from '../../ProjectPath'; +import {Config} from '../../../common/config/private/Config'; +import {ThumbnailSourceType} from '../fileaccess/PhotoWorker'; +import * as path from 'path'; +import {Utils} from '../../../common/Utils'; +import {QueryParams} from '../../../common/QueryParams'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; + +export interface MediaDTOWithThPath extends MediaDTO { + thumbnailPath: string; + thumbnailUrl: string; +} + +export abstract class Messenger = Record> { + + public abstract get Name(): string; + protected config: C; + public readonly ConfigTemplate: DynamicConfig[] = []; + + private async getThumbnail(m: MediaDTO) { + return await PhotoProcessing.generateThumbnail( + path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name), + Config.Media.Thumbnail.thumbnailSizes[0], + MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video, + false + ); + } + + + public async send(config: C, input: string | MediaDTO[] | unknown) { + if (Array.isArray(input) && input.length > 0 + && (input as MediaDTO[])[0]?.name + && (input as MediaDTO[])[0]?.directory + && (input as MediaDTO[])[0]?.metadata?.creationDate) { + const media = input as MediaDTOWithThPath[]; + for (let i = 0; i < media.length; ++i) { + media[i].thumbnailPath = await this.getThumbnail(media[i]); + media[i].thumbnailUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) + + '?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name); + } + return await this.sendMedia(config, media); + } + // TODO: implement other branches + throw new Error('Not yet implemented'); + } + + protected abstract sendMedia(config: C, media: MediaDTOWithThPath[]): Promise ; +} diff --git a/src/backend/model/messenger/MessengerRepository.ts b/src/backend/model/messenger/MessengerRepository.ts new file mode 100644 index 00000000..c6d162ee --- /dev/null +++ b/src/backend/model/messenger/MessengerRepository.ts @@ -0,0 +1,34 @@ +import {Messenger} from './Messenger'; +import {EmailMessenger} from './EmailMessenger'; +import {StdoutMessenger} from './StdoutMessenger'; + +export class MessengerRepository { + + private static instance: MessengerRepository = null; + messengers: { [key: string]: Messenger } = {}; + + public static get Instance(): MessengerRepository { + if (MessengerRepository.instance == null) { + MessengerRepository.instance = new MessengerRepository(); + } + return MessengerRepository.instance; + } + + getAll(): Messenger[] { + return Object.values(this.messengers); + } + + register(msgr: Messenger): void { + if (typeof this.messengers[msgr.Name] !== 'undefined') { + throw new Error('Messenger already exist:' + msgr.Name); + } + this.messengers[msgr.Name] = msgr; + } + + get(name: string): Messenger { + return this.messengers[name]; + } +} + +MessengerRepository.Instance.register(new EmailMessenger()); +MessengerRepository.Instance.register(new StdoutMessenger()); diff --git a/src/backend/model/messenger/StdoutMessenger.ts b/src/backend/model/messenger/StdoutMessenger.ts new file mode 100644 index 00000000..1edbf20a --- /dev/null +++ b/src/backend/model/messenger/StdoutMessenger.ts @@ -0,0 +1,17 @@ +import {MediaDTOWithThPath, Messenger} from './Messenger'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; +import {DefaultMessengers} from '../../../common/entities/job/JobDTO'; + +export class StdoutMessenger extends Messenger { + public readonly Name = DefaultMessengers[DefaultMessengers.Stdout]; + public readonly ConfigTemplate: DynamicConfig[] = []; + + constructor() { + super(); + } + + + protected async sendMedia(config: never, media: MediaDTOWithThPath[]) { + console.log(media.map(m => m.thumbnailPath)); + } +} diff --git a/src/backend/routes/admin/AdminRouter.ts b/src/backend/routes/admin/AdminRouter.ts index 26a1f949..1ad4e31d 100644 --- a/src/backend/routes/admin/AdminRouter.ts +++ b/src/backend/routes/admin/AdminRouter.ts @@ -10,6 +10,7 @@ export class AdminRouter { this.addGetStatistic(app); this.addGetDuplicates(app); this.addJobs(app); + this.addMessengers(app); } private static addGetStatistic(app: Express): void { @@ -32,6 +33,15 @@ export class AdminRouter { ); } + private static addMessengers(app: Express): void { + app.get( + Config.Server.apiPath + '/admin/messengers/available', + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + AdminMWs.getAvailableMessengers, + RenderingMWs.renderResult + ); + } private static addJobs(app: Express): void { app.get( Config.Server.apiPath + '/admin/jobs/available', diff --git a/src/common/BackendTexts.ts b/src/common/BackendTexts.ts index 966d198b..de825cd9 100644 --- a/src/common/BackendTexts.ts +++ b/src/common/BackendTexts.ts @@ -1,4 +1,5 @@ export type backendText = number; +// keep the numbering sparse to support later addition export const backendTexts = { indexedFilesOnly: {name: 10, description: 12}, sizeToGenerate: {name: 20, description: 22}, @@ -6,6 +7,7 @@ export const backendTexts = { mediaPick: {name: 40, description: 42}, emailTo: {name: 70, description: 72}, emailSubject: {name: 90, description: 92}, - emailText: {name: 100, description: 102} + emailText: {name: 100, description: 102}, + messenger: {name: 110,description: 112} }; diff --git a/src/common/entities/DynamicConfig.ts b/src/common/entities/DynamicConfig.ts index becfc97b..14b5129e 100644 --- a/src/common/entities/DynamicConfig.ts +++ b/src/common/entities/DynamicConfig.ts @@ -1,5 +1,8 @@ import {backendText} from '../BackendTexts'; -import {fieldType} from './job/JobDTO'; + + +export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array' | 'messenger'; + /** * Dynamic configs are not part of the typeconfig maintained config. @@ -14,4 +17,5 @@ export interface DynamicConfig { description: backendText | string; type: fieldType; defaultValue: unknown; + validIf?: { configFiled: string, equalsValue: string }; // only shows this config if this predicate is true } diff --git a/src/common/entities/job/JobDTO.ts b/src/common/entities/job/JobDTO.ts index 3001a80f..60ed59ca 100644 --- a/src/common/entities/job/JobDTO.ts +++ b/src/common/entities/job/JobDTO.ts @@ -1,8 +1,5 @@ -import {backendText} from '../../BackendTexts'; import {DynamicConfig} from '../DynamicConfig'; -export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array'; - export enum DefaultsJobs { Indexing = 1, 'Gallery Reset' = 2, @@ -19,6 +16,12 @@ export enum DefaultsJobs { } +export enum DefaultMessengers { + Email = 1, + Stdout = 2 +} + + export interface JobDTO { Name: string; ConfigTemplate: DynamicConfig[]; diff --git a/src/frontend/app/model/backendtext.service.ts b/src/frontend/app/model/backendtext.service.ts index d814b103..d228ccb1 100644 --- a/src/frontend/app/model/backendtext.service.ts +++ b/src/frontend/app/model/backendtext.service.ts @@ -5,7 +5,10 @@ import {DefaultsJobs} from '../../../common/entities/job/JobDTO'; @Injectable() export class BackendtextService { - public get(id: backendText): string { + public get(id: backendText | string): string { + if (typeof id === 'string') { + return id; + } switch (id) { case backendTexts.sizeToGenerate.name: return $localize`Size to generate`; diff --git a/src/frontend/app/ui/settings/scheduled-jobs.service.ts b/src/frontend/app/ui/settings/scheduled-jobs.service.ts index 77a74227..fde6dd27 100644 --- a/src/frontend/app/ui/settings/scheduled-jobs.service.ts +++ b/src/frontend/app/ui/settings/scheduled-jobs.service.ts @@ -3,9 +3,10 @@ import {BehaviorSubject} from 'rxjs'; import {JobProgressDTO, JobProgressStates, OnTimerJobProgressDTO,} from '../../../../common/entities/job/JobProgressDTO'; import {NetworkService} from '../../model/network/network.service'; import {JobScheduleDTO} from '../../../../common/entities/job/JobScheduleDTO'; -import {ConfigTemplateEntry, JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO'; +import {JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO'; import {BackendtextService} from '../../model/backendtext.service'; import {NotificationService} from '../../model/notification.service'; +import {DynamicConfig} from '../../../../common/entities/DynamicConfig'; @Injectable() export class ScheduledJobsService { @@ -13,6 +14,7 @@ export class ScheduledJobsService { public onJobFinish: EventEmitter = new EventEmitter(); timer: number = null; public availableJobs: BehaviorSubject; + public availableMessengers: BehaviorSubject; public jobStartingStopping: { [key: string]: boolean } = {}; private subscribers = 0; @@ -23,6 +25,7 @@ export class ScheduledJobsService { ) { this.progress = new BehaviorSubject({}); this.availableJobs = new BehaviorSubject([]); + this.availableMessengers = new BehaviorSubject([]); } @@ -32,7 +35,13 @@ export class ScheduledJobsService { ); } - public getConfigTemplate(JobName: string): ConfigTemplateEntry[] { + public async getAvailableMessengers(): Promise { + this.availableMessengers.next( + await this.networkService.getJson('/admin/messengers/available') + ); + } + + public getConfigTemplate(JobName: string): DynamicConfig[] { const job = this.availableJobs.value.find( (t) => t.Name === JobName ); diff --git a/src/frontend/app/ui/settings/workflow/workflow.component.html b/src/frontend/app/ui/settings/workflow/workflow.component.html index 8d4b8b61..01d8fc63 100644 --- a/src/frontend/app/ui/settings/workflow/workflow.component.html +++ b/src/frontend/app/ui/settings/workflow/workflow.component.html @@ -170,6 +170,7 @@
@@ -227,6 +228,18 @@ placeholder="Search Query"> + + + diff --git a/src/frontend/app/ui/settings/workflow/workflow.component.ts b/src/frontend/app/ui/settings/workflow/workflow.component.ts index 6b8be5b0..95633782 100644 --- a/src/frontend/app/ui/settings/workflow/workflow.component.ts +++ b/src/frontend/app/ui/settings/workflow/workflow.component.ts @@ -108,6 +108,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni ngOnInit(): void { this.jobsService.subscribeToProgress(); this.jobsService.getAvailableJobs().catch(console.error); + this.jobsService.getAvailableMessengers().catch(console.error); } ngOnDestroy(): void { @@ -128,7 +129,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni schedule.config = schedule.config || {}; if (job.ConfigTemplate) { job.ConfigTemplate.forEach( - (ct) => (schedule.config[ct.id] = ct.defaultValue) + (ct) => (schedule.config[ct.id] = ct.defaultValue as never) ); } } @@ -216,7 +217,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni this.newSchedule.config = this.newSchedule.config || {}; if (job.ConfigTemplate) { job.ConfigTemplate.forEach( - (ct) => (this.newSchedule.config[ct.id] = ct.defaultValue) + (ct) => (this.newSchedule.config[ct.id] = ct.defaultValue as never) ); } this.jobModalQL.first.show(); From ebb9886d4b69310aadd4a33e31d9359123bc9c1a Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 19 Nov 2023 01:43:10 +0100 Subject: [PATCH 12/15] Add messenger extensibility to extensions #753 --- .../model/extension/ExpressRouterWrapper.ts | 8 +++-- .../model/extension/ExtensionManager.ts | 9 +++--- .../extension/ExtensionMessengerHandler.ts | 31 +++++++++++++++++++ .../model/extension/ExtensionObject.ts | 3 ++ src/backend/model/extension/IExtension.ts | 29 +++++++++++++++-- .../model/messenger/ExtensionMessenger.ts | 15 +++++++++ .../model/messenger/MessengerRepository.ts | 7 +++++ 7 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 src/backend/model/extension/ExtensionMessengerHandler.ts create mode 100644 src/backend/model/messenger/ExtensionMessenger.ts diff --git a/src/backend/model/extension/ExpressRouterWrapper.ts b/src/backend/model/extension/ExpressRouterWrapper.ts index 6f033126..550d1797 100644 --- a/src/backend/model/extension/ExpressRouterWrapper.ts +++ b/src/backend/model/extension/ExpressRouterWrapper.ts @@ -62,7 +62,9 @@ export class ExpressRouteWrapper implements IExtensionRESTRoute { }, RenderingMWs.renderResult ]))); - this.extLogger.silly(`Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + const p = ExtensionManager.EXTENSION_API_PATH + fullPaths; + this.extLogger.silly(`Listening on ${this.func} ${p}`); + return p; } public rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise) { @@ -70,6 +72,8 @@ export class ExpressRouteWrapper implements IExtensionRESTRoute { this.router[this.func](fullPaths, ...this.getAuthMWs(minRole), mw); - this.extLogger.silly(`Listening on ${this.func} ${ExtensionManager.EXTENSION_API_PATH}${fullPaths}`); + const p = ExtensionManager.EXTENSION_API_PATH + fullPaths; + this.extLogger.silly(`Listening on ${this.func} ${p}`); + return p; } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 1d9ee898..2fd4b36e 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import {IObjectManager} from '../database/IObjectManager'; import {Logger} from '../../Logger'; -import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension'; +import {IExtensionEvents, IExtensionObject} from './IExtension'; import {Server} from '../../server'; import {ExtensionEvent} from './ExtensionEvent'; import * as express from 'express'; @@ -108,8 +108,8 @@ export class ExtensionManager implements IObjectManager { if (fs.existsSync(packageJsonPath)) { Logger.silly(LOG_TAG, `Running: "npm install --omit=dev" in ${extPath}`); - await exec('npm install --omit=dev' ,{ - cwd:extPath + await exec('npm install --omit=dev', { + cwd: extPath }); // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require(packageJsonPath); @@ -138,8 +138,9 @@ export class ExtensionManager implements IObjectManager { const ext = require(serverExt); if (typeof ext?.cleanUp === 'function') { Logger.debug(LOG_TAG, 'Running Init on extension:' + extObj.extensionName); - await ext?.cleanUp(ext); + await ext?.cleanUp(extObj); } + extObj.messengers.cleanUp(); } } diff --git a/src/backend/model/extension/ExtensionMessengerHandler.ts b/src/backend/model/extension/ExtensionMessengerHandler.ts new file mode 100644 index 00000000..ff7aeddb --- /dev/null +++ b/src/backend/model/extension/ExtensionMessengerHandler.ts @@ -0,0 +1,31 @@ +import {IExtensionMessengers} from './IExtension'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; +import {MediaDTOWithThPath, Messenger} from '../messenger/Messenger'; +import {ExtensionMessenger} from '../messenger/ExtensionMessenger'; +import {MessengerRepository} from '../messenger/MessengerRepository'; +import {ILogger} from '../../Logger'; + +export class ExtensionMessengerHandler implements IExtensionMessengers { + + messengers: Messenger[] = []; + + + constructor(private readonly extLogger: ILogger) { + } + + + addMessenger>(name: string, config: DynamicConfig[], callbacks: { + sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise + }): void { + this.extLogger.silly('Adding new Messenger:', name); + const em = new ExtensionMessenger(name, config, callbacks); + this.messengers.push(em); + MessengerRepository.Instance.register(em); + } + + cleanUp() { + this.extLogger.silly('Removing Messenger'); + this.messengers.forEach(m => MessengerRepository.Instance.remove(m)); + } + +} diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index dcc35ed1..3ff1aec6 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -6,6 +6,7 @@ import {ProjectPath} from '../../ProjectPath'; import {ExpressRouterWrapper} from './ExpressRouterWrapper'; import {createLoggerWrapper} from '../../Logger'; import * as express from 'express'; +import {ExtensionMessengerHandler} from './ExtensionMessengerHandler'; export class ExtensionObject implements IExtensionObject { @@ -16,6 +17,7 @@ export class ExtensionObject implements IExtensionObject { public readonly Logger; public readonly events; public readonly RESTApi; + public readonly messengers; constructor(public readonly extensionId: string, public readonly extensionName: string, @@ -30,6 +32,7 @@ export class ExtensionObject implements IExtensionObject { this.Logger = logger; this.events = events; this.RESTApi = new ExpressRouterWrapper(extensionRouter, extensionId, logger); + this.messengers = new ExtensionMessengerHandler(logger); } } diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index 038cdf7a..3ce85fce 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -7,6 +7,8 @@ import {ILogger} from '../../Logger'; import {UserDTO, UserRoles} from '../../../common/entities/UserDTO'; import {ParamsDictionary} from 'express-serve-static-core'; import {Connection} from 'typeorm'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; +import {MediaDTOWithThPath} from '../messenger/Messenger'; export type IExtensionBeforeEventHandler = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>; @@ -69,16 +71,18 @@ export interface IExtensionRESTRoute { * @param paths RESTapi path, relative to the extension base endpoint * @param minRole set to null to omit auer check (ie make the endpoint public) * @param cb function callback + * @return newly added REST api path */ - jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): void; + jsonResponse(paths: string[], minRole: UserRoles, cb: (params?: ParamsDictionary, body?: any, user?: UserDTO) => Promise | unknown): string; /** * Exposes a standard expressjs middleware * @param paths RESTapi path, relative to the extension base endpoint * @param minRole set to null to omit auer check (ie make the endpoint public) * @param mw expressjs middleware + * @return newly added REST api path */ - rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): void; + rawMiddleware(paths: string[], minRole: UserRoles, mw: (req: Request, res: Response, next: NextFunction) => void | Promise): string; } export interface IExtensionRESTApi { @@ -116,9 +120,21 @@ export interface IExtensionConfig { getConfig(): C; } +export interface IExtensionMessengers { + /** + * Adds a new messenger that the user can select e.g.: for sending top pick photos + * @param name Name of the messenger (also used as id) + * @param config config metadata for this messenger + * @param callbacks messenger logic + */ + addMessenger = Record>(name: string, config: DynamicConfig[], callbacks: { + sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise + }): void; +} + export interface IExtensionObject { /** - * ID of the extension that is internally used. By default the name and ID matches if there is no collision. + * ID of the extension that is internally used. By default, the name and ID matches if there is no collision. */ extensionId: string, @@ -159,6 +175,13 @@ export interface IExtensionObject { * Use this to define REST calls related to the extension */ RESTApi: IExtensionRESTApi; + + /** + * Object to manipulate messengers. + * Messengers are used to send messages (like emails) from the app. + * One type of message is a list of selected photos. + */ + messengers: IExtensionMessengers; } diff --git a/src/backend/model/messenger/ExtensionMessenger.ts b/src/backend/model/messenger/ExtensionMessenger.ts new file mode 100644 index 00000000..24d7caf5 --- /dev/null +++ b/src/backend/model/messenger/ExtensionMessenger.ts @@ -0,0 +1,15 @@ +import {MediaDTOWithThPath, Messenger} from './Messenger'; +import {DynamicConfig} from '../../../common/entities/DynamicConfig'; + +export class ExtensionMessenger = Record> extends Messenger { + + constructor(public readonly Name: string, + public readonly ConfigTemplate: DynamicConfig[], + private readonly callbacks: { sendMedia: (config: C, media: MediaDTOWithThPath[]) => Promise }) { + super(); + } + + protected sendMedia(config: C, media: MediaDTOWithThPath[]): Promise { + return this.callbacks.sendMedia(config, media); + } +} diff --git a/src/backend/model/messenger/MessengerRepository.ts b/src/backend/model/messenger/MessengerRepository.ts index c6d162ee..31a98163 100644 --- a/src/backend/model/messenger/MessengerRepository.ts +++ b/src/backend/model/messenger/MessengerRepository.ts @@ -18,6 +18,13 @@ export class MessengerRepository { return Object.values(this.messengers); } + remove(m: Messenger>): void { + if (!this.messengers[m.Name]) { + throw new Error('Messenger does not exist:' + m.Name); + } + delete this.messengers[m.Name]; + } + register(msgr: Messenger): void { if (typeof this.messengers[msgr.Name] !== 'undefined') { throw new Error('Messenger already exist:' + msgr.Name); From dfd26e9b6b24bea4cfbe54cbb5fdddba26ebc227 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 19 Nov 2023 16:12:18 +0100 Subject: [PATCH 13/15] Improving extension #753 --- src/backend/model/extension/ExtensionManager.ts | 4 ++-- src/backend/model/extension/IExtension.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 2fd4b36e..f02dfb28 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -107,8 +107,8 @@ export class ExtensionManager implements IObjectManager { } if (fs.existsSync(packageJsonPath)) { - Logger.silly(LOG_TAG, `Running: "npm install --omit=dev" in ${extPath}`); - await exec('npm install --omit=dev', { + Logger.silly(LOG_TAG, `Running: "npm install --prefer-offline --no-audit --progress=false --omit=dev" in ${extPath}`); + await exec('npm install --no-audit --progress=false --omit=dev', { cwd: extPath }); // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/backend/model/extension/IExtension.ts b/src/backend/model/extension/IExtension.ts index 3ce85fce..cd59cf0e 100644 --- a/src/backend/model/extension/IExtension.ts +++ b/src/backend/model/extension/IExtension.ts @@ -132,7 +132,7 @@ export interface IExtensionMessengers { }): void; } -export interface IExtensionObject { +export interface IExtensionObject { /** * ID of the extension that is internally used. By default, the name and ID matches if there is no collision. */ From b9a6207b965951b0998d6e2a8898a13c55f994c1 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 19 Nov 2023 16:14:09 +0100 Subject: [PATCH 14/15] Add localized text to messengers #753 --- src/frontend/app/model/backendtext.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/frontend/app/model/backendtext.service.ts b/src/frontend/app/model/backendtext.service.ts index d228ccb1..1d69706a 100644 --- a/src/frontend/app/model/backendtext.service.ts +++ b/src/frontend/app/model/backendtext.service.ts @@ -38,6 +38,10 @@ export class BackendtextService { return $localize`Message`; case backendTexts.emailText.description: return $localize`E-mail text.`; + case backendTexts.messenger.name: + return $localize`Messenger`; + case backendTexts.messenger.description: + return $localize`Messenger to send this message with.`; default: return null; } From d7ca7cbe15ea784648dc67b2e7ba83cc36febb42 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 19 Nov 2023 23:03:54 +0100 Subject: [PATCH 15/15] remove logger --- package-lock.json | 18 +++++------------- package.json | 1 - 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 929a93a7..2b317e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "fluent-ffmpeg": "2.1.2", "image-size": "1.0.2", "locale": "0.1.0", - "logger": "file:extensions/logger", "node-geocoder": "4.2.0", "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", @@ -135,6 +134,7 @@ }, "extensions/logger": { "version": "1.0.0", + "extraneous": true, "dependencies": { "lodash": "4.17.21" } @@ -15598,7 +15598,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash-es": { "version": "4.17.21", @@ -15855,10 +15856,6 @@ "node": ">=8.0" } }, - "node_modules/logger": { - "resolved": "extensions/logger", - "link": true - }, "node_modules/loupe": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", @@ -35953,7 +35950,8 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash-es": { "version": "4.17.21", @@ -36157,12 +36155,6 @@ "streamroller": "^3.0.6" } }, - "logger": { - "version": "file:extensions/logger", - "requires": { - "lodash": "4.17.21" - } - }, "loupe": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", diff --git a/package.json b/package.json index 5925f30d..3f22ff7e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "fluent-ffmpeg": "2.1.2", "image-size": "1.0.2", "locale": "0.1.0", - "logger": "file:extensions/logger", "node-geocoder": "4.2.0", "nodemailer": "6.9.4", "reflect-metadata": "0.1.13",
\n' + - ' ' + media[i].name + '\n' + + ' ' + media[i].name + '\n' + caption + '