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/benchmark/BenchmarkRunner.ts b/benchmark/BenchmarkRunner.ts index 30e4e941..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.InitSQLManagers(); + 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.InitSQLManagers(); + 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.InitSQLManagers(); + await ObjectManagers.getInstance().init(); }; private async setupDB(): Promise { diff --git a/package-lock.json b/package-lock.json index fe5711e8..2b317e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,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", @@ -132,6 +132,13 @@ "mysql": "2.18.1" } }, + "extensions/logger": { + "version": "1.0.0", + "extraneous": true, + "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", @@ -8646,9 +8653,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": { @@ -30554,9 +30561,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 db95cf66..3f22ff7e 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", @@ -113,7 +113,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/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 99d5e030..d6dc6603 100644 --- a/src/backend/ProjectPath.ts +++ b/src/backend/ProjectPath.ts @@ -2,28 +2,29 @@ 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() { 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); } @@ -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, 'extensions'); // 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/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/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/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index fe688644..0e7af1ca 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -3,9 +3,10 @@ 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'; 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/ObjectManagers.ts b/src/backend/model/ObjectManagers.ts index 8733bb39..ed70be7e 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,11 +33,99 @@ export class ObjectManagers { private jobManager: JobManager; 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; } @@ -169,62 +258,15 @@ export class ObjectManagers { this.managers.push(this.jobManager as IObjectManager); } - public static getInstance(): ObjectManagers { - if (this.instance === null) { - this.instance = new ObjectManagers(); - } - return this.instance; + get ExtensionManager(): ExtensionManager { + return this.extensionManager; } - 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; - } - if (ObjectManagers.getInstance().JobManager) { - ObjectManagers.getInstance().JobManager.stopSchedules(); - } - await SQLConnection.close(); - this.instance = null; - Logger.debug(LOG_TAG, 'Object manager reset'); - } - - public static async InitSQLManagers(): Promise { - await ObjectManagers.reset(); - await SQLConnection.init(); - this.initManagers(); - Logger.debug(LOG_TAG, 'SQL DB inited'); - } - - private static initManagers(): void { - 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(); - } - - 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); - } + 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); } } 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/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/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 new file mode 100644 index 00000000..550d1797 --- /dev/null +++ b/src/backend/model/extension/ExpressRouterWrapper.ts @@ -0,0 +1,79 @@ +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 {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, + private readonly extLogger: ILogger) { + } + + get use() { + return new ExpressRouteWrapper(this.router, this.name, 'use', this.extLogger); + } + + get get() { + return new ExpressRouteWrapper(this.router, this.name, 'get', this.extLogger); + } + + get put() { + return new ExpressRouteWrapper(this.router, this.name, 'put', this.extLogger); + } + + get post() { + return new ExpressRouteWrapper(this.router, this.name, 'post', this.extLogger); + } + + get delete() { + return new ExpressRouteWrapper(this.router, this.name, 'delete', this.extLogger); + } + +} + +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 extLogger: ILogger) { + } + + 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 + ]))); + 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) { + const fullPaths = paths.map(p => (Utils.concatUrls('/' + this.name + '/' + p))); + this.router[this.func](fullPaths, + ...this.getAuthMWs(minRole), + mw); + const p = ExtensionManager.EXTENSION_API_PATH + fullPaths; + this.extLogger.silly(`Listening on ${this.func} ${p}`); + return p; + } +} 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/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts new file mode 100644 index 00000000..b11e5eaa --- /dev/null +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -0,0 +1,52 @@ +import {IConfigClass} from 'typeconfig/common'; +import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; +import {ConfigClassBuilder} from '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(); + 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.'); + 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/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/ExtensionDecorator.ts b/src/backend/model/extension/ExtensionDecorator.ts new file mode 100644 index 00000000..a0870f49 --- /dev/null +++ b/src/backend/model/extension/ExtensionDecorator.ts @@ -0,0 +1,40 @@ +import {IExtensionEvent, IExtensionEvents} from './IExtension'; +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, + propertyName: string, + descriptor: PropertyDescriptor + ) => { + + const targetMethod = descriptor.value; + descriptor.value = async function(...args: I) { + if (!ExtensionDecoratorObject.events) { + return await targetMethod.apply(this, args); + } + + const event = fn(ExtensionDecoratorObject.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..f02dfb28 --- /dev/null +++ b/src/backend/model/extension/ExtensionManager.ts @@ -0,0 +1,157 @@ +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} from './IExtension'; +import {Server} from '../../server'; +import {ExtensionEvent} from './ExtensionEvent'; +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]'; + +export class ExtensionManager implements IObjectManager { + + public static EXTENSION_API_PATH = Config.Server.apiPath + '/extension'; + + events: IExtensionEvents; + extObjects: { [key: string]: ExtensionObject } = {}; + router: express.Router; + + constructor() { + this.initEvents(); + } + + 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(); + 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() + } + } + }; + ExtensionDecoratorObject.init(this.events); + } + + 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 createUniqueExtensionObject(name: string, folder: string): IExtensionObject { + let id = name; + if (this.extObjects[id]) { + let i = 0; + while (this.extObjects[`${name}_${++i}`]) { /* empty */ + } + id = `${name}_${++i}`; + } + if (!this.extObjects[id]) { + this.extObjects[id] = new ExtensionObject(id, name, folder, this.router, this.events); + } + return this.extObjects[id]; + } + + private async initExtensions() { + + 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 --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 + 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(); + } + } + + private async cleanUpExtensions() { + 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:' + extObj.extensionName); + await ext?.cleanUp(extObj); + } + extObj.messengers.cleanUp(); + } + } + + + 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()); + this.extObjects = {}; + } +} 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 new file mode 100644 index 00000000..3ff1aec6 --- /dev/null +++ b/src/backend/model/extension/ExtensionObject.ts @@ -0,0 +1,38 @@ +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'; +import {ExtensionMessengerHandler} from './ExtensionMessengerHandler'; + +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; + public readonly messengers; + + 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); + this.db = new ExtensionDB(logger); + this.paths = ProjectPath; + 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 new file mode 100644 index 00000000..cd59cf0e --- /dev/null +++ b/src/backend/model/extension/IExtension.ts @@ -0,0 +1,199 @@ +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'; +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>; +export type IExtensionAfterEventHandler = (output: O) => Promise; + + +export interface IExtensionEvent { + before: (handler: IExtensionBeforeEventHandler) => void; + after: (handler: IExtensionAfterEventHandler) => void; +} + +/** + * All main event callbacks in the app + */ +export interface IExtensionEvents { + gallery: { + /** + * 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 + } + }; +} + +export interface IExtensionApp { + expressApp: express.Express; + objectManagers: ObjectManagers; + config: PrivateConfigClass; +} + +export interface IExtensionRESTRoute { + /** + * Sends a pigallery2 standard JSON object with payload or error message back to the client. + * @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): 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): string; +} + +export interface IExtensionRESTApi { + use: IExtensionRESTRoute; + get: IExtensionRESTRoute; + post: IExtensionRESTRoute; + put: IExtensionRESTRoute; + 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 IExtensionConfig { + setTemplate(template: new() => C): void; + + 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. + */ + 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. + */ + _app: IExtensionApp; + + /** + * Create extension related configuration + */ + config: IExtensionConfig; + + /** + * Create new SQL tables and access SQL connection + */ + db: IExtensionDB; + + /** + * 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; + + /** + * 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; +} + + +/** + * 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/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 88be5394..b6382266 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'; import * as exifr from 'exifr'; import * as path from 'path'; @@ -19,6 +20,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 = { @@ -153,6 +156,7 @@ export class MetadataLoader { fileSize: 0, }; + @ExtensionDecorator(e=>e.gallery.MetadataLoader.loadPhotoMetadata) public static loadPhotoMetadata(fullPath: string): Promise { return new Promise((resolve, reject) => { try { @@ -189,7 +193,7 @@ export class MetadataLoader { fullPathWithoutExt + '.xmp', fullPathWithoutExt + '.XMP', ]; - + for (const sidecarPath of sidecarPaths) { if (fs.existsSync(sidecarPath)) { const sidecarData = exifr.sidecar(sidecarPath); 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/model/jobs/JobManager.ts b/src/backend/model/jobs/JobManager.ts index 525da36d..fc0f7a87 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(); } @@ -49,7 +50,7 @@ export class JobManager implements IJobListener { return prg; } - public async run( + public async run>( jobName: string, config: T, soloRun: boolean, @@ -85,7 +86,7 @@ export class JobManager implements IJobListener { }; onJobFinished = async ( - job: IJob, + job: IJob, state: JobProgressStates, soloRun: boolean ): Promise => { @@ -120,11 +121,16 @@ export class JobManager implements IJobListener { } }; - getAvailableJobs(): IJob[] { + getAvailableJobs(): IJob[] { 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 = []; } @@ -138,7 +144,7 @@ export class JobManager implements IJobListener { 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/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/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/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 { 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 56c8667c..2ba0450c 100644 --- a/src/backend/model/jobs/jobs/TopPickSendJob.ts +++ b/src/backend/model/jobs/jobs/TopPickSendJob.ts @@ -1,63 +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: ConfigTemplateEntry[] = [ - { - 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'; @@ -85,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); @@ -102,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/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/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..31a98163 --- /dev/null +++ b/src/backend/model/messenger/MessengerRepository.ts @@ -0,0 +1,41 @@ +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); + } + + 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); + } + 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/backend/server.ts b/src/backend/server.ts index dd32d7e6..6e9899e5 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 = null; + + public static getInstance(): Server { + if (!this.instance) { + this.instance = new Server(); + } + return this.instance; + } + constructor() { if (!(process.env.NODE_ENV === 'production')) { Logger.info( @@ -45,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( @@ -61,13 +75,14 @@ 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(); - - LoggerRouter.route(this.app); - - this.app.set('view engine', 'ejs'); /** * Session above all @@ -115,7 +130,7 @@ export class Server { Localizations.init(); this.app.use(locale(Config.Server.languages, 'en')); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.getInstance().init(); Router.route(this.app); 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/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/MessagingConfig.ts b/src/common/config/private/MessagingConfig.ts index 1a01c5c3..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; diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 896b617e..24adc83b 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, @@ -25,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'; @@ -1013,6 +1013,25 @@ export class ServerServiceConfig extends ClientServiceConfig { Log: ServerLogConfig = new ServerLogConfig(); } + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsConfig extends ClientExtensionsConfig { + @ConfigProperty({volatile: true}) + list: string[] = []; + + @ConfigProperty({type: 'object'}) + configs: Record = {}; + + @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}) export class ServerEnvironmentConfig { @ConfigProperty({volatile: true}) @@ -1133,6 +1152,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/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/src/common/entities/DynamicConfig.ts b/src/common/entities/DynamicConfig.ts new file mode 100644 index 00000000..14b5129e --- /dev/null +++ b/src/common/entities/DynamicConfig.ts @@ -0,0 +1,21 @@ +import {backendText} from '../BackendTexts'; + + +export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array' | 'messenger'; + + +/** + * 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; + 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 ac98c2b6..60ed59ca 100644 --- a/src/common/entities/job/JobDTO.ts +++ b/src/common/entities/job/JobDTO.ts @@ -1,6 +1,4 @@ -import {backendText} from '../../BackendTexts'; - -export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array'; +import {DynamicConfig} from '../DynamicConfig'; export enum DefaultsJobs { Indexing = 1, @@ -17,17 +15,16 @@ export enum DefaultsJobs { 'Top Pick Sending' = 12 } -export interface ConfigTemplateEntry { - id: string; - name: backendText; - description: backendText; - type: fieldType; - defaultValue: any; + +export enum DefaultMessengers { + Email = 1, + Stdout = 2 } + export interface JobDTO { Name: string; - ConfigTemplate: ConfigTemplateEntry[]; + ConfigTemplate: DynamicConfig[]; } export const JobDTOUtils = { diff --git a/src/frontend/app/model/backendtext.service.ts b/src/frontend/app/model/backendtext.service.ts index d814b103..1d69706a 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`; @@ -35,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; } 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/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/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 cdbc0dc6..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'; @@ -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/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/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(); diff --git a/test/backend/DBTestHelper.ts b/test/backend/DBTestHelper.ts index 823c322b..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.InitSQLManagers(); + 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.InitSQLManagers(); + 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.InitSQLManagers(); + 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 3de22df9..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.InitSQLManagers(); + 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 679b8106..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.InitSQLManagers(); + 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 f70d3b5f..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.InitSQLManagers(); + 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 5aa90736..3fa3bea6 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,10 +35,10 @@ 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) + 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 ad20f8ad..72129189 100644 --- a/test/backend/unit/middlewares/admin/SettingsMWs.ts +++ b/test/backend/unit/middlewares/admin/SettingsMWs.ts @@ -6,9 +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; @@ -21,7 +22,7 @@ describe('Settings middleware', () => { beforeEach(async () => { await ObjectManagers.reset(); await fs.promises.rm(tempDir, {recursive: true, force: true}); - await ObjectManagers.InitSQLManagers(); + await ObjectManagers.getInstance().init(); }); it('should save empty enforced users settings', (done: (err?: any) => void) => { @@ -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'); diff --git a/test/backend/unit/model/sql/AlbumManager.spec.ts b/test/backend/unit/model/sql/AlbumManager.spec.ts index d54c1591..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.InitSQLManagers(); + 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 f6e5c2ac..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.InitSQLManagers(); + await ObjectManagers.getInstance().init(); }; @@ -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' diff --git a/test/backend/unit/model/sql/SearchManager.spec.ts b/test/backend/unit/model/sql/SearchManager.spec.ts index ffc7091b..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.InitSQLManagers(); + 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); });
\n' + - ' ' + media[i].name + '\n' + + ' ' + media[i].name + '\n' + caption + '