From b1362ffef515804c3c174dfa2c1cec32369f3d1a Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 24 Dec 2023 08:59:36 +0100 Subject: [PATCH 01/18] Refactor extension settings --- .../model/extension/ExtensionConfigWrapper.ts | 39 ++++++++-- .../model/extension/ExtensionManager.ts | 31 +++++--- .../model/extension/ExtensionObject.ts | 2 +- src/common/config/private/PrivateConfig.ts | 33 +------- .../{ => subconfigs}/MessagingConfig.ts | 2 +- .../subconfigs/ServerExtensionsConfig.ts | 78 +++++++++++++++++++ .../settings-entry.component.ts | 4 + 7 files changed, 139 insertions(+), 50 deletions(-) rename src/common/config/private/{ => subconfigs}/MessagingConfig.ts (97%) create mode 100644 src/common/config/private/subconfigs/ServerExtensionsConfig.ts diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index b11e5eaa..cad0ad24 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,9 +1,9 @@ -import {IConfigClass} from 'typeconfig/common'; +import {ConfigProperty, 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'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; /** * Wraps to original config and makes sure all extension related config is loaded @@ -29,11 +29,29 @@ export class ExtensionConfigWrapper { export class ExtensionConfig implements IExtensionConfig { public template: new() => C; - constructor(private readonly extensionId: string) { + constructor(private readonly extensionFolder: string) { + } + + private findConfig(config: PrivateConfigClass) { + let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); + if (!c) { + c = new ServerExtensionsEntryConfig(this.extensionFolder); + config.Extensions.extensions.push(c); + } + + if (!config.Extensions.extensions2[this.extensionFolder]) { + Object.defineProperty(config.Extensions.extensions2, this.extensionFolder, + ConfigProperty({type: ServerExtensionsEntryConfig})(config.Extensions.extensions2, this.extensionFolder)); + // config.Extensions.extensions2[this.extensionFolder] = c as any; + + config.Extensions.extensions2[this.extensionFolder] = c; + + } + return config.Extensions.extensions2[this.extensionFolder]; } public getConfig(): C { - return Config.Extensions.configs[this.extensionId] as C; + return this.findConfig(Config).configs as C; } public setTemplate(template: new() => C): void { @@ -45,8 +63,15 @@ export class ExtensionConfig implements IExtensionConfig { 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; + + const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); + const extConf = this.findConfig(config); + // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); + //extConf.configs = confTemplate; + Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, + ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); + console.log(config.Extensions.extensions2[this.extensionFolder].configs); + config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; + console.log(config.Extensions.extensions2[this.extensionFolder].configs); } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index f02dfb28..11f50aa4 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -12,6 +12,7 @@ import {SQLConnection} from '../database/SQLConnection'; import {ExtensionObject} from './ExtensionObject'; import {ExtensionDecoratorObject} from './ExtensionDecorator'; import * as util from 'util'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; // eslint-disable-next-line @typescript-eslint/no-var-requires const exec = util.promisify(require('child_process').exec); @@ -70,13 +71,23 @@ export class ExtensionManager implements IObjectManager { 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)); + + const extList = fs + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); + extList.sort(); + + // delete not existing extensions + Config.Extensions.extensions = Config.Extensions.extensions.filter(ec => extList.indexOf(ec.path) !== -1); + + // Add new extensions + const ePaths = Config.Extensions.extensions.map(ec => ec.path); + extList.filter(ep => ePaths.indexOf(ep) === -1).forEach(ep => + Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); + + Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.extensions.map(ec => ec.path))); } private createUniqueExtensionObject(name: string, folder: string): IExtensionObject { @@ -95,8 +106,8 @@ export class ExtensionManager implements IObjectManager { private async initExtensions() { - for (let i = 0; i < Config.Extensions.list.length; ++i) { - const extFolder = Config.Extensions.list[i]; + for (let i = 0; i < Config.Extensions.extensions.length; ++i) { + const extFolder = Config.Extensions.extensions[i].path; let extName = extFolder; const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); const serverExtPath = path.join(extPath, 'server.js'); @@ -122,7 +133,7 @@ export class ExtensionManager implements IObjectManager { 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)); + await ext?.init(this.createUniqueExtensionObject(extName, extFolder)); } } if (Config.Extensions.cleanUpUnusedTables) { diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index 3ff1aec6..254c8b9d 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -26,7 +26,7 @@ export class ExtensionObject implements IExtensionObject { events: IExtensionEvents) { const logger = createLoggerWrapper(`[Extension][${extensionId}]`); this._app = new ExtensionApp(); - this.config = new ExtensionConfig(extensionId); + this.config = new ExtensionConfig(folder); this.db = new ExtensionDB(logger); this.paths = ProjectPath; this.Logger = logger; diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index a02dd3af..a537011d 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -11,7 +11,6 @@ import { } from '../../entities/job/JobScheduleDTO'; import { ClientConfig, - ClientExtensionsConfig, ClientGPXCompressingConfig, ClientMediaConfig, ClientMetaFileConfig, @@ -32,7 +31,8 @@ import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/Sear import {SortByTypes} from '../../entities/SortingMethods'; import {UserRoles} from '../../entities/UserDTO'; import {MediaPickDTO} from '../../entities/MediaPickDTO'; -import {MessagingConfig} from './MessagingConfig'; +import {ServerExtensionsConfig} from './subconfigs/ServerExtensionsConfig'; +import {MessagingConfig} from './subconfigs/MessagingConfig'; declare let $localize: (s: TemplateStringsArray) => string; @@ -1025,35 +1025,6 @@ export class ServerServiceConfig extends ClientServiceConfig { } -@SubConfigClass({softReadonly: true}) -export class ServerExtensionsConfig extends ClientExtensionsConfig { - - @ConfigProperty({ - tags: { - name: $localize`Extension folder`, - priority: ConfigPriority.underTheHood, - dockerSensitive: true - }, - description: $localize`Folder where the app stores the extensions. Extensions live in their sub-folders.`, - }) - folder: string = 'extensions'; - - @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}) diff --git a/src/common/config/private/MessagingConfig.ts b/src/common/config/private/subconfigs/MessagingConfig.ts similarity index 97% rename from src/common/config/private/MessagingConfig.ts rename to src/common/config/private/subconfigs/MessagingConfig.ts index 067a19f7..54ec6602 100644 --- a/src/common/config/private/MessagingConfig.ts +++ b/src/common/config/private/subconfigs/MessagingConfig.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; -import {ConfigPriority, TAGS} from '../public/ClientConfig'; +import {ConfigPriority, TAGS} from '../../public/ClientConfig'; declare let $localize: (s: TemplateStringsArray) => string; diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts new file mode 100644 index 00000000..22f7ad66 --- /dev/null +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-inferrable-types */ +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; +import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; +import {IConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass'; + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsEntryConfig { + + constructor(path: string = '') { + this.path = path; + } + + @ConfigProperty({ + tags: { + name: $localize`Enabled`, + priority: ConfigPriority.advanced, + }, + }) + enabled: boolean = true; + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + path: string = ''; + + @ConfigProperty({ + tags: { + name: $localize`Config`, + priority: ConfigPriority.advanced + } + }) + configs: IConfigClassPrivate; +} + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsConfig extends ClientExtensionsConfig { + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + dockerSensitive: true + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + folder: string = 'extensions'; + + + @ConfigProperty({ + arrayType: ServerExtensionsEntryConfig, + tags: { + name: $localize`Installed extensions`, + priority: ConfigPriority.advanced + } + }) + extensions: ServerExtensionsEntryConfig[] = []; + + @ConfigProperty({ + tags: { + name: $localize`Installed extensions2`, + priority: ConfigPriority.advanced + } + }) + extensions2: 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; +} 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 333add00..1d235785 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 @@ -19,6 +19,7 @@ import {enumToTranslatedArray} from '../../../EnumTranslations'; import {BsModalService} from 'ngx-bootstrap/modal'; import {CustomSettingsEntries} from '../CustomSettingsEntries'; import {GroupByTypes, SortByTypes} from '../../../../../../common/entities/SortingMethods'; +import { ServerExtensionsEntryConfig } from '../../../../../../common/config/private/subconfigs/ServerExtensionsConfig'; interface IState { shouldHide(): boolean; @@ -232,6 +233,8 @@ export class SettingsEntryComponent this.arrayType = 'MapPathGroupThemeConfig'; } else if (this.state.arrayType === UserConfig) { this.arrayType = 'UserConfig'; + } else if (this.state.arrayType === ServerExtensionsEntryConfig) { + this.arrayType = 'ServerExtensionsEntryConfig'; } else if (this.state.arrayType === JobScheduleConfig) { this.arrayType = 'JobScheduleConfig'; } else { @@ -253,6 +256,7 @@ export class SettingsEntryComponent this.arrayType !== 'MapLayers' && this.arrayType !== 'NavigationLinkConfig' && this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && this.arrayType !== 'JobScheduleConfig' && this.arrayType !== 'UserConfig') { From 90b620de00ac6247eafd7c7e747137ac92bab51c Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 24 Dec 2023 08:59:36 +0100 Subject: [PATCH 02/18] Refactor extension settings --- .../model/extension/ExtensionConfigWrapper.ts | 39 ++++++++-- .../model/extension/ExtensionManager.ts | 31 +++++--- .../model/extension/ExtensionObject.ts | 2 +- src/common/config/private/PrivateConfig.ts | 33 +------- .../{ => subconfigs}/MessagingConfig.ts | 2 +- .../subconfigs/ServerExtensionsConfig.ts | 78 +++++++++++++++++++ .../settings-entry.component.ts | 4 + 7 files changed, 139 insertions(+), 50 deletions(-) rename src/common/config/private/{ => subconfigs}/MessagingConfig.ts (97%) create mode 100644 src/common/config/private/subconfigs/ServerExtensionsConfig.ts diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index b11e5eaa..cad0ad24 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,9 +1,9 @@ -import {IConfigClass} from 'typeconfig/common'; +import {ConfigProperty, 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'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; /** * Wraps to original config and makes sure all extension related config is loaded @@ -29,11 +29,29 @@ export class ExtensionConfigWrapper { export class ExtensionConfig implements IExtensionConfig { public template: new() => C; - constructor(private readonly extensionId: string) { + constructor(private readonly extensionFolder: string) { + } + + private findConfig(config: PrivateConfigClass) { + let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); + if (!c) { + c = new ServerExtensionsEntryConfig(this.extensionFolder); + config.Extensions.extensions.push(c); + } + + if (!config.Extensions.extensions2[this.extensionFolder]) { + Object.defineProperty(config.Extensions.extensions2, this.extensionFolder, + ConfigProperty({type: ServerExtensionsEntryConfig})(config.Extensions.extensions2, this.extensionFolder)); + // config.Extensions.extensions2[this.extensionFolder] = c as any; + + config.Extensions.extensions2[this.extensionFolder] = c; + + } + return config.Extensions.extensions2[this.extensionFolder]; } public getConfig(): C { - return Config.Extensions.configs[this.extensionId] as C; + return this.findConfig(Config).configs as C; } public setTemplate(template: new() => C): void { @@ -45,8 +63,15 @@ export class ExtensionConfig implements IExtensionConfig { 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; + + const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); + const extConf = this.findConfig(config); + // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); + //extConf.configs = confTemplate; + Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, + ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); + console.log(config.Extensions.extensions2[this.extensionFolder].configs); + config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; + console.log(config.Extensions.extensions2[this.extensionFolder].configs); } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index f02dfb28..11f50aa4 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -12,6 +12,7 @@ import {SQLConnection} from '../database/SQLConnection'; import {ExtensionObject} from './ExtensionObject'; import {ExtensionDecoratorObject} from './ExtensionDecorator'; import * as util from 'util'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; // eslint-disable-next-line @typescript-eslint/no-var-requires const exec = util.promisify(require('child_process').exec); @@ -70,13 +71,23 @@ export class ExtensionManager implements IObjectManager { 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)); + + const extList = fs + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); + extList.sort(); + + // delete not existing extensions + Config.Extensions.extensions = Config.Extensions.extensions.filter(ec => extList.indexOf(ec.path) !== -1); + + // Add new extensions + const ePaths = Config.Extensions.extensions.map(ec => ec.path); + extList.filter(ep => ePaths.indexOf(ep) === -1).forEach(ep => + Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); + + Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.extensions.map(ec => ec.path))); } private createUniqueExtensionObject(name: string, folder: string): IExtensionObject { @@ -95,8 +106,8 @@ export class ExtensionManager implements IObjectManager { private async initExtensions() { - for (let i = 0; i < Config.Extensions.list.length; ++i) { - const extFolder = Config.Extensions.list[i]; + for (let i = 0; i < Config.Extensions.extensions.length; ++i) { + const extFolder = Config.Extensions.extensions[i].path; let extName = extFolder; const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); const serverExtPath = path.join(extPath, 'server.js'); @@ -122,7 +133,7 @@ export class ExtensionManager implements IObjectManager { 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)); + await ext?.init(this.createUniqueExtensionObject(extName, extFolder)); } } if (Config.Extensions.cleanUpUnusedTables) { diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index 3ff1aec6..254c8b9d 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -26,7 +26,7 @@ export class ExtensionObject implements IExtensionObject { events: IExtensionEvents) { const logger = createLoggerWrapper(`[Extension][${extensionId}]`); this._app = new ExtensionApp(); - this.config = new ExtensionConfig(extensionId); + this.config = new ExtensionConfig(folder); this.db = new ExtensionDB(logger); this.paths = ProjectPath; this.Logger = logger; diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 5375b9dd..721c02fc 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -11,7 +11,6 @@ import { } from '../../entities/job/JobScheduleDTO'; import { ClientConfig, - ClientExtensionsConfig, ClientGPXCompressingConfig, ClientMediaConfig, ClientMetaFileConfig, @@ -30,7 +29,8 @@ import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/Sear import {SortByTypes} from '../../entities/SortingMethods'; import {UserRoles} from '../../entities/UserDTO'; import {MediaPickDTO} from '../../entities/MediaPickDTO'; -import {MessagingConfig} from './MessagingConfig'; +import {ServerExtensionsConfig} from './subconfigs/ServerExtensionsConfig'; +import {MessagingConfig} from './subconfigs/MessagingConfig'; declare let $localize: (s: TemplateStringsArray) => string; @@ -966,35 +966,6 @@ export class ServerServiceConfig extends ClientServiceConfig { } -@SubConfigClass({softReadonly: true}) -export class ServerExtensionsConfig extends ClientExtensionsConfig { - - @ConfigProperty({ - tags: { - name: $localize`Extension folder`, - priority: ConfigPriority.underTheHood, - dockerSensitive: true - }, - description: $localize`Folder where the app stores the extensions. Extensions live in their sub-folders.`, - }) - folder: string = 'extensions'; - - @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}) diff --git a/src/common/config/private/MessagingConfig.ts b/src/common/config/private/subconfigs/MessagingConfig.ts similarity index 97% rename from src/common/config/private/MessagingConfig.ts rename to src/common/config/private/subconfigs/MessagingConfig.ts index 067a19f7..54ec6602 100644 --- a/src/common/config/private/MessagingConfig.ts +++ b/src/common/config/private/subconfigs/MessagingConfig.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; -import {ConfigPriority, TAGS} from '../public/ClientConfig'; +import {ConfigPriority, TAGS} from '../../public/ClientConfig'; declare let $localize: (s: TemplateStringsArray) => string; diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts new file mode 100644 index 00000000..22f7ad66 --- /dev/null +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-inferrable-types */ +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; +import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; +import {IConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass'; + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsEntryConfig { + + constructor(path: string = '') { + this.path = path; + } + + @ConfigProperty({ + tags: { + name: $localize`Enabled`, + priority: ConfigPriority.advanced, + }, + }) + enabled: boolean = true; + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + path: string = ''; + + @ConfigProperty({ + tags: { + name: $localize`Config`, + priority: ConfigPriority.advanced + } + }) + configs: IConfigClassPrivate; +} + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsConfig extends ClientExtensionsConfig { + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + dockerSensitive: true + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + folder: string = 'extensions'; + + + @ConfigProperty({ + arrayType: ServerExtensionsEntryConfig, + tags: { + name: $localize`Installed extensions`, + priority: ConfigPriority.advanced + } + }) + extensions: ServerExtensionsEntryConfig[] = []; + + @ConfigProperty({ + tags: { + name: $localize`Installed extensions2`, + priority: ConfigPriority.advanced + } + }) + extensions2: 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; +} 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 333add00..1d235785 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 @@ -19,6 +19,7 @@ import {enumToTranslatedArray} from '../../../EnumTranslations'; import {BsModalService} from 'ngx-bootstrap/modal'; import {CustomSettingsEntries} from '../CustomSettingsEntries'; import {GroupByTypes, SortByTypes} from '../../../../../../common/entities/SortingMethods'; +import { ServerExtensionsEntryConfig } from '../../../../../../common/config/private/subconfigs/ServerExtensionsConfig'; interface IState { shouldHide(): boolean; @@ -232,6 +233,8 @@ export class SettingsEntryComponent this.arrayType = 'MapPathGroupThemeConfig'; } else if (this.state.arrayType === UserConfig) { this.arrayType = 'UserConfig'; + } else if (this.state.arrayType === ServerExtensionsEntryConfig) { + this.arrayType = 'ServerExtensionsEntryConfig'; } else if (this.state.arrayType === JobScheduleConfig) { this.arrayType = 'JobScheduleConfig'; } else { @@ -253,6 +256,7 @@ export class SettingsEntryComponent this.arrayType !== 'MapLayers' && this.arrayType !== 'NavigationLinkConfig' && this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && this.arrayType !== 'JobScheduleConfig' && this.arrayType !== 'UserConfig') { From b2be0a976307cd478555a879b974d8064c59af7d Mon Sep 17 00:00:00 2001 From: grasdk <115414609+grasdk@users.noreply.github.com> Date: Sun, 11 Feb 2024 15:55:26 +0100 Subject: [PATCH 03/18] consolidate exif parsing libraries - rework of timestamps (#4) * exifr is used for most tags now * New timestamp handling, removed exif-parser, date supported for png * Removed offset from testhelper. It's optional * explanations * Feature/timestamps (#3) * preparing for further timestamp test * Added more test and fixed offset calculation bug * Revered old dimension test, added new timestamp tests, some bug fixes * Renamed png-test because faces overrule keywords --- package-lock.json | 1 - package.json | 1 - .../model/database/enitites/MediaEntity.ts | 4 + .../model/fileaccess/MetadataLoader.ts | 403 ++++++++++-------- src/common/entities/MediaDTO.ts | 1 + src/common/entities/PhotoDTO.ts | 1 + src/common/entities/VideoDTO.ts | 1 + test/backend/assets/Chars.json | 3 +- .../edge_case_exif_data/before_epoch.json | 3 +- .../edge_case_exif_data/date_error.json | 2 +- ...tes.json => png_with_faces_and_dates.json} | 3 +- ...dates.png => png_with_faces_and_dates.png} | Bin test/backend/assets/test_png.json | 3 +- test/backend/assets/timestamps/big_ben.jpg | Bin 0 -> 18532 bytes test/backend/assets/timestamps/big_ben.json | 25 ++ .../big_ben_no_tsoffset_but_gps_utc.jpg | Bin 0 -> 18663 bytes .../big_ben_no_tsoffset_but_gps_utc.json | 25 ++ .../assets/timestamps/big_ben_only_time.jpg | Bin 0 -> 17850 bytes .../assets/timestamps/big_ben_only_time.json | 20 + .../assets/timestamps/sydney_opera_house.jpg | Bin 0 -> 22755 bytes .../assets/timestamps/sydney_opera_house.json | 25 ++ ...ey_opera_house_no_tsoffset_but_gps_utc.jpg | Bin 0 -> 22653 bytes ...y_opera_house_no_tsoffset_but_gps_utc.json | 25 ++ test/backend/assets/two_ratings.json | 3 +- test/backend/assets/xmp/xmp_subject.json | 3 +- .../model/threading/MetaDataLoader.spec.ts | 32 +- 26 files changed, 408 insertions(+), 176 deletions(-) rename test/backend/assets/{png_with_keyword_and_dates.json => png_with_faces_and_dates.json} (85%) rename test/backend/assets/{png_with_keyword_and_dates.png => png_with_faces_and_dates.png} (100%) create mode 100644 test/backend/assets/timestamps/big_ben.jpg create mode 100644 test/backend/assets/timestamps/big_ben.json create mode 100644 test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg create mode 100644 test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json create mode 100644 test/backend/assets/timestamps/big_ben_only_time.jpg create mode 100644 test/backend/assets/timestamps/big_ben_only_time.json create mode 100644 test/backend/assets/timestamps/sydney_opera_house.jpg create mode 100644 test/backend/assets/timestamps/sydney_opera_house.json create mode 100644 test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg create mode 100644 test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json diff --git a/package-lock.json b/package-lock.json index db09d172..51b2286a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", "sharp": "0.31.3", - "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", "typeconfig": "2.1.2", "typeorm": "0.3.12", diff --git a/package.json b/package.json index f3dac2e6..7219c2c0 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", "sharp": "0.31.3", - "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", "typeconfig": "2.1.2", "typeorm": "0.3.12", diff --git a/src/backend/model/database/enitites/MediaEntity.ts b/src/backend/model/database/enitites/MediaEntity.ts index 52bbf600..57c13132 100644 --- a/src/backend/model/database/enitites/MediaEntity.ts +++ b/src/backend/model/database/enitites/MediaEntity.ts @@ -105,6 +105,10 @@ export class MediaMetadataEntity implements MediaMetadata { }) @Index() creationDate: number; + + @Column('text') + creationDateOffset?: string; + @Column('int', {unsigned: true}) fileSize: number; diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 8ded9e1d..3e10e1fa 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -12,7 +12,6 @@ import { FfprobeData } from 'fluent-ffmpeg'; import { FileHandle } from 'fs/promises'; import * as util from 'node:util'; import * as path from 'path'; -import { ExifParserFactory, OrientationTypes } from 'ts-exif-parser'; import { IptcParser } from 'ts-node-iptc'; import { Utils } from '../../../common/Utils'; import { FFmpegFactory } from '../FFmpegFactory'; @@ -135,7 +134,7 @@ export class MetadataLoader { fullPathWithoutExt + '.xmp', fullPathWithoutExt + '.XMP', ]; - + for (const sidecarPath of sidecarPaths) { if (fs.existsSync(sidecarPath)) { const sidecarData = await exifr.sidecar(sidecarPath); @@ -148,7 +147,8 @@ export class MetadataLoader { if (metadata.keywords.indexOf(kw) === -1) { metadata.keywords.push(kw); } - } } + } + } if ((sidecarData as SideCar).xmp.Rating !== undefined) { metadata.rating = (sidecarData as SideCar).xmp.Rating; } @@ -168,7 +168,7 @@ export class MetadataLoader { } private static readonly EMPTY_METADATA: PhotoMetadata = { - size: {width: 1, height: 1}, + size: { width: 0, height: 0 }, creationDate: 0, fileSize: 0, }; @@ -177,10 +177,55 @@ export class MetadataLoader { public static async loadPhotoMetadata(fullPath: string): Promise { let fileHandle: FileHandle; const metadata: PhotoMetadata = { - size: {width: 1, height: 1}, + size: { width: 0, height: 0 }, creationDate: 0, fileSize: 0, }; + const exifrOptions = { + tiff: true, + xmp: true, + icc: false, + jfif: false, //not needed and not supported for png + ihdr: true, + iptc: false, //exifr reads UTF8-encoded data wrongly, using IptcParser instead + exif: true, + gps: true, + reviveValues: false, //don't convert timestamps + translateValues: false, //don't translate orientation from numbers to strings etc. + mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged + }; + + //function to convert timestamp into milliseconds taking offset into account + const timestampToMS = (timestamp: string, offset: string) => { + "replace first two : with - in timestamp string and add offset if exists (else +00:00 - UTC), parse this into MS" + return Date.parse(timestamp.replace(':', '-').replace(':', '-') + (offset ? offset : '+00:00')); + } + + const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { + let UTCTimestamp = gpsTimeStamp; + if (!UTCTimestamp && + gps && + gps.GPSDateStamp && + gps.GPSTimeStamp) { + //GPS timestamp is always UTC (+00:00) + UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':') + '+00:00'; + } + if (UTCTimestamp) { + //offset in minutes is the difference between gps timestamp and given timestamp + let offsetMinutes = (Date.parse(UTCTimestamp) - Date.parse(timestamp.replace(':', '-').replace(':', '-'))) / 1000 / 60; + if (-720 <= offsetMinutes && offsetMinutes <= 840) { + //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) + return (offsetMinutes < 0 ? "-" : "+") + //leading +/- + ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' + ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes + } else { + return undefined; + } + } else { + return undefined; + } + } + try { const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); fileHandle = await fs.promises.open(fullPath, 'r'); @@ -193,7 +238,6 @@ export class MetadataLoader { } finally { await fileHandle.close(); } - try { try { const stat = fs.statSync(fullPath); @@ -202,118 +246,17 @@ export class MetadataLoader { } catch (err) { // ignoring errors } - try { - const exif = ExifParserFactory.create(data).parse(); - if ( - exif.tags.ISO || - exif.tags.Model || - exif.tags.Make || - exif.tags.FNumber || - exif.tags.ExposureTime || - exif.tags.FocalLength || - exif.tags.LensModel - ) { - if (exif.tags.Model && exif.tags.Model !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.model = '' + exif.tags.Model; - } - if (exif.tags.Make && exif.tags.Make !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.make = '' + exif.tags.Make; - } - if (exif.tags.LensModel && exif.tags.LensModel !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.lens = '' + exif.tags.LensModel; - } - if (Utils.isUInt32(exif.tags.ISO)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.ISO = parseInt('' + exif.tags.ISO, 10); - } - if (Utils.isFloat32(exif.tags.FocalLength)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.focalLength = parseFloat( - '' + exif.tags.FocalLength - ); - } - if (Utils.isFloat32(exif.tags.ExposureTime)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.exposure = parseFloat( - parseFloat('' + exif.tags.ExposureTime).toFixed(6) - ); - } - if (Utils.isFloat32(exif.tags.FNumber)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.fStop = parseFloat( - parseFloat('' + exif.tags.FNumber).toFixed(2) - ); - } - } - if ( - !isNaN(exif.tags.GPSLatitude) || - exif.tags.GPSLongitude || - exif.tags.GPSAltitude - ) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.GPSData = {}; - - if (Utils.isFloat32(exif.tags.GPSLongitude)) { - metadata.positionData.GPSData.longitude = parseFloat( - exif.tags.GPSLongitude.toFixed(6) - ); - } - if (Utils.isFloat32(exif.tags.GPSLatitude)) { - metadata.positionData.GPSData.latitude = parseFloat( - exif.tags.GPSLatitude.toFixed(6) - ); - } - } - if ( - exif.tags.CreateDate || - exif.tags.DateTimeOriginal || - exif.tags.ModifyDate - ) { - metadata.creationDate = - (exif.tags.DateTimeOriginal || - exif.tags.CreateDate || - exif.tags.ModifyDate) * 1000; - } - if (exif.imageSize) { - metadata.size = { - width: exif.imageSize.width, - height: exif.imageSize.height, - }; - } else if ( - exif.tags.RelatedImageWidth && - exif.tags.RelatedImageHeight - ) { - metadata.size = { - width: exif.tags.RelatedImageWidth, - height: exif.tags.RelatedImageHeight, - }; - } else if ( - exif.tags.ImageWidth && - exif.tags.ImageHeight - ) { - metadata.size = { - width: exif.tags.ImageWidth, - height: exif.tags.ImageHeight, - }; - } else { - const info = imageSize(fullPath); - metadata.size = {width: info.width, height: info.height}; - } - } catch (err) { - Logger.debug(LOG_TAG, 'Error parsing exif', fullPath, err); - try { - const info = imageSize(fullPath); - metadata.size = {width: info.width, height: info.height}; - } catch (e) { - metadata.size = {width: 1, height: 1}; - } + //read the actual image size, don't rely on tags for this + const info = imageSize(fullPath); + metadata.size = { width: info.width, height: info.height }; + } catch (e) { + //in case of failure, set dimensions to 0 so they may be read via tags + metadata.size = { width: 0, height: 0 }; } - try { + + try { //Parse iptc data using the IptcParser, which works correctly for both UTF-8 and ASCII const iptcData = IptcParser.parse(data); if (iptcData.country_or_primary_location_name) { metadata.positionData = metadata.positionData || {}; @@ -351,61 +294,187 @@ export class MetadataLoader { // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); } - if (!metadata.creationDate) { - // creationDate can be negative, when it was created before epoch (1970) - metadata.creationDate = 0; - } - try { - const exifrOptions = { - tiff: true, - xmp: true, - icc: false, - jfif: false, //not needed and not supported for png - ihdr: true, - iptc: false, //exifr reads UTF8-encoded data wrongly - exif: true, - gps: true, - translateValues: false, //don't translate orientation from numbers to strings etc. - mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged - }; - - const exif = await exifr.parse(data, exifrOptions); - if (exif.xmp && exif.xmp.Rating) { - metadata.rating = exif.xmp.Rating; - if (metadata.rating < 0) { - metadata.rating = 0; - } - } + let orientation = 1; //Orientation 1 is normal + const exif = await exifr.parse(data, exifrOptions); + //exif is structured in sections, we read the data by section + + //dc-section (subject is the only tag we want from dc) if (exif.dc && - exif.dc.subject && - exif.dc.subject.length > 0) { + exif.dc.subject && + exif.dc.subject.length > 0) { const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; if (metadata.keywords === undefined) { - metadata.keywords = []; + metadata.keywords = []; } for (const kw of subj) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } } - } - let orientation = OrientationTypes.TOP_LEFT; - if (exif.ifd0 && - exif.ifd0.Orientation) { - orientation = parseInt( - exif.ifd0.Orientation as any, - 10 - ) as number; } - if (OrientationTypes.BOTTOM_LEFT < orientation) { + + //ifd0 section + if (exif.ifd0) { + if (exif.ifd0.ImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.ifd0.ImageWidth; + } + if (exif.ifd0.ImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.ifd0.ImageHeight; + } + if (exif.ifd0.Orientation) { + orientation = parseInt( + exif.ifd0.Orientation as any, + 10 + ) as number; + } + if (exif.ifd0.Make && exif.ifd0.Make !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.make = '' + exif.ifd0.Make; + } + if (exif.ifd0.Model && exif.ifd0.Model !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.model = '' + exif.ifd0.Model; + } + //if (exif.ifd0.ModifyDate) {} //Deferred to the exif-section where the other timestamps are + } + + //exif section + if (exif.exif) { + if (exif.exif.DateTimeOriginal) { + //DateTimeOriginal is when the camera shutter closed + if (exif.exif.OffsetTimeOriginal) { //OffsetTimeOriginal is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, exif.exif.OffsetTimeOriginal); + metadata.creationDateOffset = exif.exif.OffsetTimeOriginal; + } else { + let alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence + //Create is when the camera wrote the file (typically within the same ms as shutter close) + if (exif.exif.OffsetTimeDigitized) { //OffsetTimeDigitized is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.CreateDate, exif.exif.OffsetTimeDigitized); + metadata.creationDateOffset = exif.exif.OffsetTimeDigitized; + } else { + let alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence + if (exif.exif.OffsetTime) { + //exif.Offsettime is the offset corresponding to ifd0.ModifyDate + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, exif.exif?.OffsetTime); + metadata.creationDateOffset = exif.exif?.OffsetTime + } else { + let alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } + if (exif.exif.LensModel && exif.exif.LensModel !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.lens = '' + exif.exif.LensModel; + } + if (Utils.isUInt32(exif.exif.ISO)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10); + } + if (Utils.isFloat32(exif.exif.FocalLength)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.focalLength = parseFloat( + '' + exif.exif.FocalLength + ); + } + if (Utils.isFloat32(exif.exif.ExposureTime)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.exposure = parseFloat( + parseFloat('' + exif.exif.ExposureTime).toFixed(6) + ); + } + if (Utils.isFloat32(exif.exif.FNumber)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.fStop = parseFloat( + parseFloat('' + exif.exif.FNumber).toFixed(2) + ); + } + if (exif.exif.ExifImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.exif.ExifImageWidth; + } + if (exif.exif.ExifImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.exif.ExifImageHeight; + } + } + + //gps section + if (exif.gps) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.GPSData = metadata.positionData.GPSData || {}; + + if (Utils.isFloat32(exif.gps.longitude)) { + metadata.positionData.GPSData.longitude = parseFloat( + exif.gps.longitude.toFixed(6) + ); + } + if (Utils.isFloat32(exif.gps.latitude)) { + metadata.positionData.GPSData.latitude = parseFloat( + exif.gps.latitude.toFixed(6) + ); + } + + if (metadata.positionData) { + if (!metadata.positionData.GPSData || + Object.keys(metadata.positionData.GPSData).length === 0) { + metadata.positionData.GPSData = undefined; + metadata.positionData = undefined; + } + } + } + //photoshop section (sometimes has City, Country and State) + if (exif.photoshop) { + function unescape(tag: string) { + return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { + return String.fromCharCode(parseInt(numStr, 10)); + }); + } + + if (!metadata.positionData?.country && exif.photoshop.Country) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = unescape(exif.photoshop.Country); + } + if (!metadata.positionData?.state && exif.photoshop.State) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.state = unescape(exif.photoshop.State); + } + if (!metadata.positionData?.city && exif.photoshop.City) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.city = unescape(exif.photoshop.City); + } + } + + /////////////////////////////////////// + metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive + metadata.size.width = Math.max(metadata.size.width, 1); //ensure width dimension is positive + + //Before moving on to the XMP section (particularly the regions (mwg-rs)) + //we need to switch width and height for images that are rotated sideways + if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%) // noinspection JSSuspiciousNameCombination const height = metadata.size.width; // noinspection JSSuspiciousNameCombination metadata.size.width = metadata.size.height; metadata.size.height = height; } + /////////////////////////////////////// + //xmp section + if (exif.xmp && exif.xmp.Rating) { + metadata.rating = exif.xmp.Rating; + if (metadata.rating < 0) { + metadata.rating = 0; + } + } + //xmp."mwg-rs" section if (Config.Faces.enabled && exif["mwg-rs"] && exif["mwg-rs"].Regions) { @@ -422,24 +491,24 @@ export class MetadataLoader { x: string, y: string ) => { - if (OrientationTypes.BOTTOM_LEFT < orientation) { + if (4 < orientation) { //roation is sidewards (90 or 270 degrees) [x, y] = [y, x]; [w, h] = [h, w]; } let swapX = 0; let swapY = 0; switch (orientation) { - case OrientationTypes.TOP_RIGHT: - case OrientationTypes.RIGHT_TOP: + case 2: //TOP RIGHT (Mirror horizontal): + case 6: //RIGHT TOP (Rotate 90 CW) swapX = 1; break; - case OrientationTypes.BOTTOM_RIGHT: - case OrientationTypes.RIGHT_BOTTOM: + case 3: // BOTTOM RIGHT (Rotate 180) + case 7: // RIGHT BOTTOM (Mirror horizontal and rotate 90 CW) swapX = 1; swapY = 1; break; - case OrientationTypes.BOTTOM_LEFT: - case OrientationTypes.LEFT_BOTTOM: + case 4: //BOTTOM_LEFT (Mirror vertical) + case 8: //LEFT_BOTTOM (Rotate 270 CW) swapY = 1; break; } @@ -451,7 +520,6 @@ export class MetadataLoader { top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height), }; }; - /* Adobe Lightroom based face region structure */ if ( regionRoot && @@ -497,7 +565,7 @@ export class MetadataLoader { box.top = Math.round(Math.max(0, box.top - box.height / 2)); - faces.push({name, box}); + faces.push({ name, box }); } } if (faces.length > 0) { @@ -517,6 +585,11 @@ export class MetadataLoader { // ignoring errors } + if (!metadata.creationDate) { + // creationDate can be negative, when it was created before epoch (1970) + metadata.creationDate = 0; + } + try { // search for sidecar and merge metadata const fullPathWithoutExt = path.parse(fullPath).name; @@ -564,7 +637,5 @@ export class MetadataLoader { return MetadataLoader.EMPTY_METADATA; } return metadata; - - } } diff --git a/src/common/entities/MediaDTO.ts b/src/common/entities/MediaDTO.ts index 9c282abe..7bcdcd99 100644 --- a/src/common/entities/MediaDTO.ts +++ b/src/common/entities/MediaDTO.ts @@ -17,6 +17,7 @@ export interface MediaMetadata { size: MediaDimension; creationDate: number; fileSize: number; + creationDateOffset?: string; keywords?: string[]; rating?: RatingTypes; title?: string; diff --git a/src/common/entities/PhotoDTO.ts b/src/common/entities/PhotoDTO.ts index a4af1c24..6184bbd2 100644 --- a/src/common/entities/PhotoDTO.ts +++ b/src/common/entities/PhotoDTO.ts @@ -33,6 +33,7 @@ export interface PhotoMetadata extends MediaMetadata { positionData?: PositionMetaData; size: MediaDimension; creationDate: number; + creationDateOffset?: string; fileSize: number; faces?: FaceRegion[]; } diff --git a/src/common/entities/VideoDTO.ts b/src/common/entities/VideoDTO.ts index d9cefefc..ef0e39fd 100644 --- a/src/common/entities/VideoDTO.ts +++ b/src/common/entities/VideoDTO.ts @@ -11,6 +11,7 @@ export interface VideoDTO extends MediaDTO { export interface VideoMetadata extends MediaMetadata { size: MediaDimension; creationDate: number; + creationDateOffset?: string; bitRate: number; duration: number; // in milliseconds fileSize: number; diff --git a/test/backend/assets/Chars.json b/test/backend/assets/Chars.json index 281eb7c9..83bec784 100644 --- a/test/backend/assets/Chars.json +++ b/test/backend/assets/Chars.json @@ -3,7 +3,8 @@ "width": 1920, "height": 1080 }, - "creationDate": 1706659327000, + "creationDate": 1706655727000, + "creationDateOffset": "+01:00", "fileSize": 111432, "positionData": { "GPSData": { diff --git a/test/backend/assets/edge_case_exif_data/before_epoch.json b/test/backend/assets/edge_case_exif_data/before_epoch.json index 51ddb9a6..982679dd 100644 --- a/test/backend/assets/edge_case_exif_data/before_epoch.json +++ b/test/backend/assets/edge_case_exif_data/before_epoch.json @@ -9,7 +9,8 @@ "model": "Canon EOS 600D" }, "caption": "Bambi Caption", - "creationDate": -11630935227000, + "creationDate": -11630942427000, + "creationDateOffset": "+02:00", "faces": [ { "box": { diff --git a/test/backend/assets/edge_case_exif_data/date_error.json b/test/backend/assets/edge_case_exif_data/date_error.json index 7600f4da..7d973302 100644 --- a/test/backend/assets/edge_case_exif_data/date_error.json +++ b/test/backend/assets/edge_case_exif_data/date_error.json @@ -7,7 +7,7 @@ "make": "NIKON", "model": "E880" }, - "creationDate": -2211753600000, + "creationDate": 0, "fileSize": 72850, "size": { "height": 768, diff --git a/test/backend/assets/png_with_keyword_and_dates.json b/test/backend/assets/png_with_faces_and_dates.json similarity index 85% rename from test/backend/assets/png_with_keyword_and_dates.json rename to test/backend/assets/png_with_faces_and_dates.json index 2f4ad94e..a7272249 100644 --- a/test/backend/assets/png_with_keyword_and_dates.json +++ b/test/backend/assets/png_with_faces_and_dates.json @@ -4,7 +4,8 @@ "width": 26, "height": 26 }, - "creationDate": 1707167247786, + "creationDate": 1599990007000, + "creationDateOffset": "+05:00", "fileSize": 5758, "keywords": [ ], diff --git a/test/backend/assets/png_with_keyword_and_dates.png b/test/backend/assets/png_with_faces_and_dates.png similarity index 100% rename from test/backend/assets/png_with_keyword_and_dates.png rename to test/backend/assets/png_with_faces_and_dates.png diff --git a/test/backend/assets/test_png.json b/test/backend/assets/test_png.json index 88800147..7b528aac 100644 --- a/test/backend/assets/test_png.json +++ b/test/backend/assets/test_png.json @@ -24,5 +24,6 @@ "size": { "height": 26, "width": 26 - } + }, + "creationDate": 1707171121504 } diff --git a/test/backend/assets/timestamps/big_ben.jpg b/test/backend/assets/timestamps/big_ben.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0649be533dc6f48db824dfd3c56bfce117247e64 GIT binary patch literal 18532 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 literal 0 HcmV?d00001 diff --git a/test/backend/assets/timestamps/big_ben.json b/test/backend/assets/timestamps/big_ben.json new file mode 100644 index 00000000..c4080036 --- /dev/null +++ b/test/backend/assets/timestamps/big_ben.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686141955000, + "creationDateOffset": "+01:00", + "fileSize": 18532, + "cameraData": { + "model": "Canon EOS R5", + "make": "Canon" + }, + "positionData": { + "GPSData": { + "longitude": -0.124575, + "latitude": 51.500694 + }, + "country": "Storbritannien", + "state": "England", + "city": "St James's" + }, + "keywords": [ + "Big Ben" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 0000000000000000000000000000000000000000..48897cf140d88e31f01c5fcbd89cbf46ce8c4916 GIT binary patch literal 18663 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 W2-!cur~wv|^}Tf)tK#6p$^Qq^NGX8; literal 0 HcmV?d00001 diff --git a/test/backend/assets/timestamps/big_ben_only_time.json b/test/backend/assets/timestamps/big_ben_only_time.json new file mode 100644 index 00000000..5113607d --- /dev/null +++ b/test/backend/assets/timestamps/big_ben_only_time.json @@ -0,0 +1,20 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686145555000, + "fileSize": 17850, + "cameraData": { + "model": "Canon EOS R5", + "make": "Canon" + }, + "positionData": { + "country": "Storbritannien", + "state": "England", + "city": "St James's" + }, + "keywords": [ + "Big Ben" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/sydney_opera_house.jpg b/test/backend/assets/timestamps/sydney_opera_house.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b595487ed5c187ce6f0430dd7e5505bbb262fc50 GIT binary patch literal 22755 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 literal 0 HcmV?d00001 diff --git a/test/backend/assets/timestamps/sydney_opera_house.json b/test/backend/assets/timestamps/sydney_opera_house.json new file mode 100644 index 00000000..dda6cff5 --- /dev/null +++ b/test/backend/assets/timestamps/sydney_opera_house.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 300, + "height": 200 + }, + "creationDate": 1600512957000, + "creationDateOffset": "+10:00", + "fileSize": 22755, + "cameraData": { + "model": "ILCE-7RM3", + "make": "Sony" + }, + "positionData": { + "GPSData": { + "longitude": 151.210381, + "latitude": -33.855698 + }, + "country": "Australien", + "state": "New South Wales", + "city": "Dawes Point" + }, + "keywords": [ + "Sydney Opera House" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f1c9a652d337a90e9c251477c8cd2121fa807884 GIT binary patch literal 22653 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�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 literal 0 HcmV?d00001 diff --git a/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json new file mode 100644 index 00000000..e5f78368 --- /dev/null +++ b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 300, + "height": 200 + }, + "creationDate": 1600512957000, + "creationDateOffset": "+10:00", + "fileSize": 22653, + "cameraData": { + "model": "ILCE-7RM3", + "make": "Sony" + }, + "positionData": { + "GPSData": { + "longitude": 151.210381, + "latitude": -33.855698 + }, + "country": "Australien", + "state": "New South Wales", + "city": "Dawes Point" + }, + "keywords": [ + "Sydney Opera House" + ] +} \ No newline at end of file diff --git a/test/backend/assets/two_ratings.json b/test/backend/assets/two_ratings.json index 924232b0..67f1b08e 100644 --- a/test/backend/assets/two_ratings.json +++ b/test/backend/assets/two_ratings.json @@ -7,7 +7,8 @@ "make": "samsung", "model": "SM-G975F" }, - "creationDate": 1619181527000, + "creationDate": 1619174327000, + "creationDateOffset": "+02:00", "fileSize": 4877, "rating":3, "size": { diff --git a/test/backend/assets/xmp/xmp_subject.json b/test/backend/assets/xmp/xmp_subject.json index a9a46f4a..a8a641ef 100644 --- a/test/backend/assets/xmp/xmp_subject.json +++ b/test/backend/assets/xmp/xmp_subject.json @@ -7,7 +7,8 @@ "make": "samsung", "model": "SM-G975F" }, - "creationDate": 1614703656000, + "creationDate": 1614700056000, + "creationDateOffset": "+01:00", "fileSize": 4709, "keywords": [ "Max", diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts index 0e074069..5a0e7e49 100644 --- a/test/backend/unit/model/threading/MetaDataLoader.spec.ts +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -24,11 +24,16 @@ describe('MetadataLoader', () => { it('should load png', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/test_png.png')); - delete data.creationDate; // creation time for png not supported const expected = require(path.join(__dirname, '/../../../assets/test_png.json')); expect(Utils.clone(data)).to.be.deep.equal(expected); }); + it('should load png with faces and dates', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/png_with_faces_and_dates.png')); + const expected = require(path.join(__dirname, '/../../../assets/png_with_faces_and_dates.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.jpg')); const expected = require(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.json')); @@ -69,6 +74,31 @@ describe('MetadataLoader', () => { expect(Utils.clone(data)).to.be.deep.equal(expected); }); + it('should load jpg with timestamps, timezone AEST (UTC+10) and gps (UTC)', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps and gps (UTC) and calculate offset +10', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps, timezone BST (UTC+1) and gps (UTC)', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps and gps (UTC) and calculate offset +1', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps but no offset and no GPS to calculate it from', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben_only_time.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben_only_time.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); describe('should load jpg with proper height and orientation', () => { it('jpg 1', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif.jpg')); From a6035f5b6570f255a597251301c6aa4196339867 Mon Sep 17 00:00:00 2001 From: gras Date: Sun, 11 Feb 2024 17:08:21 +0100 Subject: [PATCH 04/18] fixed style errors --- .../model/fileaccess/MetadataLoader.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 3e10e1fa..bb5e07b3 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -201,18 +201,19 @@ export class MetadataLoader { return Date.parse(timestamp.replace(':', '-').replace(':', '-') + (offset ? offset : '+00:00')); } + //function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { - let UTCTimestamp = gpsTimeStamp; + let UTCTimestamp = gpsTimeStamp; //use the exif.exif.gpsTimestamp if available if (!UTCTimestamp && gps && gps.GPSDateStamp && - gps.GPSTimeStamp) { + gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available //GPS timestamp is always UTC (+00:00) UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':') + '+00:00'; } if (UTCTimestamp) { //offset in minutes is the difference between gps timestamp and given timestamp - let offsetMinutes = (Date.parse(UTCTimestamp) - Date.parse(timestamp.replace(':', '-').replace(':', '-'))) / 1000 / 60; + const offsetMinutes = (Date.parse(UTCTimestamp) - Date.parse(timestamp.replace(':', '-').replace(':', '-'))) / 1000 / 60; if (-720 <= offsetMinutes && offsetMinutes <= 840) { //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) return (offsetMinutes < 0 ? "-" : "+") + //leading +/- @@ -226,6 +227,13 @@ export class MetadataLoader { } } + //Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section) + const unescape = (tag: string) => { + return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { + return String.fromCharCode(parseInt(numStr, 10)); + }); + } + try { const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); fileHandle = await fs.promises.open(fullPath, 'r'); @@ -347,7 +355,7 @@ export class MetadataLoader { metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, exif.exif.OffsetTimeOriginal); metadata.creationDateOffset = exif.exif.OffsetTimeOriginal; } else { - let alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + const alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); metadata.creationDateOffset = alt_offset; } @@ -357,7 +365,7 @@ export class MetadataLoader { metadata.creationDate = timestampToMS(exif.exif.CreateDate, exif.exif.OffsetTimeDigitized); metadata.creationDateOffset = exif.exif.OffsetTimeDigitized; } else { - let alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + const alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); metadata.creationDateOffset = alt_offset; } @@ -367,7 +375,7 @@ export class MetadataLoader { metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, exif.exif?.OffsetTime); metadata.creationDateOffset = exif.exif?.OffsetTime } else { - let alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + const alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); metadata.creationDateOffset = alt_offset; } @@ -432,12 +440,6 @@ export class MetadataLoader { } //photoshop section (sometimes has City, Country and State) if (exif.photoshop) { - function unescape(tag: string) { - return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { - return String.fromCharCode(parseInt(numStr, 10)); - }); - } - if (!metadata.positionData?.country && exif.photoshop.Country) { metadata.positionData = metadata.positionData || {}; metadata.positionData.country = unescape(exif.photoshop.Country); From a2c92087067c1d4f40ae64ab8abaa3f0180bd726 Mon Sep 17 00:00:00 2001 From: gras Date: Sun, 11 Feb 2024 21:41:42 +0100 Subject: [PATCH 05/18] Fixed serious error with offset calculation. Fixed bad test-data. --- .../model/fileaccess/MetadataLoader.ts | 24 +++++++++++++----- ...ey_opera_house_no_tsoffset_but_gps_utc.jpg | Bin 22653 -> 22641 bytes ...y_opera_house_no_tsoffset_but_gps_utc.json | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index bb5e07b3..03e09543 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -197,23 +197,35 @@ export class MetadataLoader { //function to convert timestamp into milliseconds taking offset into account const timestampToMS = (timestamp: string, offset: string) => { - "replace first two : with - in timestamp string and add offset if exists (else +00:00 - UTC), parse this into MS" - return Date.parse(timestamp.replace(':', '-').replace(':', '-') + (offset ? offset : '+00:00')); + if (!timestamp) { + return undefined; + } + //replace : with - in the yyyy-mm-dd part of the timestamp. + let formattedTimestamp = timestamp.substring(0,9).replaceAll(':', '-') + timestamp.substring(9,timestamp.length); + if (formattedTimestamp.indexOf("Z") > 0) { //replace Z (and what comes after the Z) with offset + formattedTimestamp.substring(0, formattedTimestamp.indexOf("Z")) + (offset ? offset : '+00:00'); + } else if (formattedTimestamp.indexOf("+") > 0) { //don't do anything + } else { //add offset + formattedTimestamp = formattedTimestamp + (offset ? offset : '+00:00'); + } + //parse into MS and return + return Date.parse(formattedTimestamp); } //function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { - let UTCTimestamp = gpsTimeStamp; //use the exif.exif.gpsTimestamp if available + let UTCTimestamp = gpsTimeStamp; if (!UTCTimestamp && gps && gps.GPSDateStamp && gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available //GPS timestamp is always UTC (+00:00) - UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':') + '+00:00'; + UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':'); } - if (UTCTimestamp) { + if (UTCTimestamp && timestamp) { //offset in minutes is the difference between gps timestamp and given timestamp - const offsetMinutes = (Date.parse(UTCTimestamp) - Date.parse(timestamp.replace(':', '-').replace(':', '-'))) / 1000 / 60; + //to calculate this correctly, we have to work with the same offset + const offsetMinutes = (timestampToMS(timestamp, '+00:00')- timestampToMS(UTCTimestamp, '+00:00')) / 1000 / 60; if (-720 <= offsetMinutes && offsetMinutes <= 840) { //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) return (offsetMinutes < 0 ? "-" : "+") + //leading +/- diff --git a/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg index f1c9a652d337a90e9c251477c8cd2121fa807884..184331398738dbff246dd6f1d9e316c383ba9acc 100644 GIT binary patch delta 44 zcmV+{0Mq~dumSO~0kE3_0Va|Q9J9Cr`vjA12M@Ez2Oa?hF*#H*Fth6j$pW*w3&Tu? C0ua3b delta 55 zcmeykf${GK#tpNX7>y Date: Sun, 11 Feb 2024 22:35:19 +0100 Subject: [PATCH 06/18] Added two more metadata dates to fall back to, instead of file date. This fixes the png-test --- src/backend/model/fileaccess/MetadataLoader.ts | 14 ++++++++++++-- test/backend/assets/test_png.json | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 03e09543..ae34c3e0 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -39,7 +39,7 @@ export class MetadataLoader { try { const stat = fs.statSync(fullPath); metadata.fileSize = stat.size; - metadata.creationDate = stat.mtime.getTime(); + metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification } catch (err) { console.log(err); // ignoring errors @@ -359,8 +359,10 @@ export class MetadataLoader { //if (exif.ifd0.ModifyDate) {} //Deferred to the exif-section where the other timestamps are } - //exif section + //exif section starting with the date sectino if (exif.exif) { + //Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date + //Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone. if (exif.exif.DateTimeOriginal) { //DateTimeOriginal is when the camera shutter closed if (exif.exif.OffsetTimeOriginal) { //OffsetTimeOriginal is the corresponding offset @@ -391,6 +393,14 @@ export class MetadataLoader { metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); metadata.creationDateOffset = alt_offset; } + } else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ihdr["Creation Time"], any_offset); + metadata.creationDateOffset = any_offset; + } else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.xmp.MetadataDate, any_offset); + metadata.creationDateOffset = any_offset; } if (exif.exif.LensModel && exif.exif.LensModel !== '') { metadata.cameraData = metadata.cameraData || {}; diff --git a/test/backend/assets/test_png.json b/test/backend/assets/test_png.json index 7b528aac..0edd5661 100644 --- a/test/backend/assets/test_png.json +++ b/test/backend/assets/test_png.json @@ -25,5 +25,5 @@ "height": 26, "width": 26 }, - "creationDate": 1707171121504 + "creationDate": 1544748139000 } From 49e861d35819df3c6423c44653b3d787317ae0da Mon Sep 17 00:00:00 2001 From: gras Date: Tue, 13 Feb 2024 23:00:24 +0100 Subject: [PATCH 07/18] package-lock auto updated --- package-lock.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51b2286a..e71ebb12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20051,12 +20051,6 @@ "node": ">=6.10" } }, - "node_modules/ts-exif-parser": { - "version": "0.2.2", - "dependencies": { - "sax": "1.2.4" - } - }, "node_modules/ts-helpers": { "version": "1.1.2", "dev": true, @@ -35084,12 +35078,6 @@ "dev": true, "optional": true }, - "ts-exif-parser": { - "version": "0.2.2", - "requires": { - "sax": "1.2.4" - } - }, "ts-helpers": { "version": "1.1.2", "dev": true, From 5e94220d6db8bc8ba1b3634c6d3536049408fbb7 Mon Sep 17 00:00:00 2001 From: grasdk <115414609+grasdk@users.noreply.github.com> Date: Fri, 16 Feb 2024 19:17:31 +0100 Subject: [PATCH 08/18] Use of offset value in the UI (#5) Added recognition of the offset value in the UI. It will be displayed if available. Caveat: Search will not take offset into account. A new year's picture taken in Sydney the 1st of January 2019 00:15:00 GMT+11, is technically taken in 31st of December 2018 in UTC. Therefore this picture won't show of in seaches where the after: parameter is set to 1st of january 2019. This is both correct and wrong at the same time. UTC-wise it is correct, local time it is not correct. I guess most people would find local time most untuitive, so there is room for improvement of the search. :) --- demo/images/dupl/big_ben_only_time.jpg | Bin 0 -> 17850 bytes demo/images/dupl/sydney_opera_house.jpg | Bin 0 -> 22755 bytes demo/images/timestamps/big_ben.jpg | Bin 0 -> 18532 bytes .../big_ben_no_tsoffset_but_gps_utc.jpg | Bin 0 -> 18663 bytes demo/images/timestamps/big_ben_only_time.jpg | Bin 0 -> 17850 bytes demo/images/timestamps/newyear_london.jpg | Bin 0 -> 77654 bytes demo/images/timestamps/newyear_sydney.jpg | Bin 0 -> 67879 bytes demo/images/timestamps/sydney_opera_house.jpg | Bin 0 -> 22755 bytes ...ey_opera_house_no_tsoffset_but_gps_utc.jpg | Bin 0 -> 22641 bytes .../model/fileaccess/MetadataLoader.ts | 1330 ++++++++--------- src/backend/model/messenger/EmailMessenger.ts | 3 +- src/backend/routes/GalleryRouter.ts | 572 +++---- src/common/Utils.ts | 14 + .../ui/duplicates/duplicates.component.html | 3 +- .../app/ui/gallery/filter/filter.service.ts | 2 +- .../controls.lightbox.gallery.component.ts | 2 +- ...info-panel.lightbox.gallery.component.html | 4 +- .../info-panel.lightbox.gallery.component.ts | 2 +- .../ui/gallery/navigator/sorting.service.ts | 2 +- 19 files changed, 975 insertions(+), 959 deletions(-) create mode 100644 demo/images/dupl/big_ben_only_time.jpg create mode 100644 demo/images/dupl/sydney_opera_house.jpg create mode 100644 demo/images/timestamps/big_ben.jpg create mode 100644 demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg create mode 100644 demo/images/timestamps/big_ben_only_time.jpg create mode 100644 demo/images/timestamps/newyear_london.jpg create mode 100644 demo/images/timestamps/newyear_sydney.jpg create mode 100644 demo/images/timestamps/sydney_opera_house.jpg create mode 100644 demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg diff --git a/demo/images/dupl/big_ben_only_time.jpg b/demo/images/dupl/big_ben_only_time.jpg new file mode 100644 index 0000000000000000000000000000000000000000..88d3bc35cf3018b385363a92f1ef9d59af8eef8b GIT binary patch literal 17850 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 W2-!cur~wv|^}Tf)tK#6p$^Qq^NGX8; literal 0 HcmV?d00001 diff --git a/demo/images/dupl/sydney_opera_house.jpg b/demo/images/dupl/sydney_opera_house.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b595487ed5c187ce6f0430dd7e5505bbb262fc50 GIT binary patch literal 22755 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 literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/big_ben.jpg b/demo/images/timestamps/big_ben.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0649be533dc6f48db824dfd3c56bfce117247e64 GIT binary patch literal 18532 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 literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg b/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 0000000000000000000000000000000000000000..48897cf140d88e31f01c5fcbd89cbf46ce8c4916 GIT binary patch literal 18663 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 W2-!cur~wv|^}Tf)tK#6p$^Qq^NGX8; literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/newyear_london.jpg b/demo/images/timestamps/newyear_london.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5a21185374823c0c5589d86cd38b2e2ef10d65c7 GIT binary patch literal 77654 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

=`L?wK% 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

9vbqO8Tp@;M12cqSzKJ62akMCKW<5jw1kV};M+ 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%0A+?Cfi(?|wUdy~YzptI*EE0> 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 GKmXapdXj|z literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/sydney_opera_house.jpg b/demo/images/timestamps/sydney_opera_house.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b595487ed5c187ce6f0430dd7e5505bbb262fc50 GIT binary patch literal 22755 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 literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg b/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 0000000000000000000000000000000000000000..184331398738dbff246dd6f1d9e316c383ba9acc GIT binary patch literal 22641 zcmeEsby!y0^6;ixX^;|-?(Xhxq(S1P;iX#?=|;M{y9Mc%R6-O)N*bgE6_9Vk@pwG< z-rw)}?sK2-`|B>)@18YlX7-v{v)0UBYhS;=UIs7~WaMQ47#J8p4*UbIS77#}y=*K2 zKwh2!Kn4H+6}Srn3m||{2zX(FFe-SD0xxVBH~$-d7+@9rg!IbR!fjnovh?urErI(#*UX%A7Y{yruo~ zE-SCBM$W;)&c?#Y1xjV(U=v{D7hvZn2LWzw0d8IZ4uFgM<5|ZbFZ@p!5tI*;`&a*O zp7pP|{}m49wh!>&FD$4Q3;+xMfjzycgLZ-g?@^$1xSx1wunzwdUkKuF?DSJP0P(g4 z0PP?G=_gG;2xEi1NFd+BPeKY1z5zLZ-g&{Hh(rW{l%I9&|cS{fkZWHs2kMP8tO#O$-)NkNysb1->3!vUL-L3030#3ED0zI z7Jw&#)d$d|Og$m4i5!!+)ro0C!a&p5&TPcQ-UF~Ty)7Ie-sGxI5EoN&IjFlUx$2Pz;NxZI;^ODw`pNPa!7rYN5Eoal_u#W{XWvi4 zKe3>itT%f7rsw~??($A<=3Jnfl28}OFB^cyGQZW|)y;zScZyr5xE<1;X8FBQKoaWi z=;q=r3AKQTfSxGyH`c$k-e~rBRu_n=8`MP-3J#ySo4lhX^meG6OkG?dTHa1z*EArm zPYIPHHa__sCarsdY({+NI_RRJkeH^|NWkg~C|akKG( zScu$24Q4ieW_Et9KjV-P>;FJ|V}QRksk(r298K;2FQoh;{UtQa!LQj_lx9rMrBhc(6sL0>UA5g{v!X?@H_QiE7Wh+XaAW6 z{Kfkh+3(ChS1oxda5V$h(wo)vPo4g1`j2Le({GXDZyp6UfDXm^bGm>v0dX*VLS24y z_}`HKSs(rk_5a$po&Ie8&B1@+1SDbgJc4df2>J2@n7Uuo+-rok4-l^+~AbK6vD#P z&GcqDxS6|L0&E-tY&=>V;4ixX2PY%=%LY2uKhgdY0JX5O^!}6mpBIo@#=mI(BQv0Xw6t{J#ruaQNxex3mIMP;>X2=m1W#n^Z=? z0%~q!A#e*qxcT`k`7JG&xp}zRnYqk)Ihgr5d3cyD*vvW2Ir-QvEX=rX4X3R9yO^Jb z1Ceqzu3(_{{-49g{~ox(5xg~s02tJ_{v93@{H&0U+h`1GKO%YkpmbY&})E&Mw;caly!~Tfe>48gL;#0F{$D5#G3PVmg>W)ka9i>+b8+zSF`Ke+@-thQ znpv3gaYD>^ZWR9~cCZUSW&RoKesiN6qgsGQ1>*^XhX-QD%+JLEVdk;~wK6ql=VRvK z;^N_iuyONov6=oU`(KFpmlNp^wZTju%=uY=y*d1mlH7X2|HGd@M(Y3J18zkB_aXn5 zf&Xi+|C;OHvcSJZ{9o_-uets$3;bKe|Mjl_n(N=Pz`sTO|Hr%j+Vh1tf*ZY_;EwL~ zDr~L1q@;f{lZzV&KLTMMPdBF< zI2D9(!Pf^15UvDaCKs@SApHIYZ}tbgcLQ79!Z#-i0IrLch9syPE(nub{RW%;2Af;E zIDj-}=)kpQxQJfIAy0px%g z;11XT_JA8`FB`xS#JB<)V7tWsz)y6`uLg3Nfm}9#ImjUeKmiB9^p+pE83T|8EPvV7 z)smCr76pSY0iKF1USFS3gQp+~0B{v|eSML4eSMV=o}w%OK)2&x{Lowg;QIpNvGSb?$){|CKqj#+-$?SG~DX}{|afFuA13w!eq7I^R<0T}@S z9v%S|2?-Gy9TgoN4HXRy;|?|^#vQCXXlR)Dm{>TtczAf|cL@mbaS5?;@o;a1z`%h# z@CYaf2q?H1Xc)NvcDe2au#kaQFidbTSO6>*3>+5Bbw8*K0E2LI7Wk`xr(0kj5s{F= z)_Y*fP5IRbQs1;*&jaXiAR#6kCW!g=Rs%E0NsLR3kPQa8ms=c$N)eoVD{ofT0>EP` z1eKsL1+^G5K2uD@Qf#aOZDh&3Zc_LMp|PZQG2sC*rTs)?2@Q&IcnV6QQVHbH zJK+?c!jX{4;bC%NEgkcaDUetCLXagev0yYP;1yPN{rH@g!vIWx%RZ4;?I~s$1qlp1 z1uQwTIHqh&VvzVVJ~((R^kO`Ecri=~%ph@O2?k8eatS^POnA(ZuWX57pQ`KEceCuF>}rRN*AAQOmbu~%v?<|@VFE$(`RZs1idalpYNvg<8<-* zI>Iv0(#`6TBLWeF=|u9U6sVX_VK9i5h)^5WPNzPPe3YzwSC=RY@AOIHL6V-kFtIR` zSCiycjVC05Yb-6o=mV@`b+8cT7L3HppqYxc{Bz=I5EyajwzP zEta_xe}j`ulM;c^(LOTfYA;XWYR{QU_RVu4_wGU%WG!;w-M7Vy?3jCkV;$@hb7Te2 z_$clh_`US4_6&4|5`CFy|2Escn{{fWDm(pJXShznhb&9k5^^%}^`sQEa~J?u{Pl@} zXbt~SWtR#1fhzis%doF5afL~fCkH!*4tLVdzLv4jDy?kllPtzAdaFROT=fMReIXl~ z%J|Q_eO2~6Vvm%QH?g$Gf)TAW6)+cRv zSA0jn)^*W+pnbPw@i<%eB|HD=hegx%4{@B#Ti&O!8hf*MRScm}5x1E#*nXu96W%2g zz52R?d!5-_o@v+B{P&aR^$c&nK<4TOBfOF0e9rv`Zg4^G6R9ll=G*VYJLUf6G^R1yzc@d~KO zj?XYgbzatY_%XU|?9xtLwe@YEEG``T9wnrhw;RsdA0(C8T6tq|X74Z1w`qhky!||` zS3LT9LDTXY5UDS=Qt@Pp8y|1Lr z)z_uDcli0sx~MS`$@deXwiCWxMdF=JKGL=R{FZ@_%Jyzn!Y3UqGp$RXmP|%>$4|YP zPdCOEaGNS!=leWe_#JjTrjso_Lzj}D7(13hPGbqmtIW<U3AVcd?}nqqiW2#u?)pGN2?KJw*BV2>AdY| zSR+dU)j{S?(To;ybeKRoq+Vyt6q+%Rr?kTx9wb|<^R_axcxJ16xX*KTqB zYo@f(Xr^5py%@&AAX4v6eV?(ZeZM6K+q|uH*B1;QCQO7m^(;U2%K3=iX~}B!K@$0v z#ebIC(OE*&E&{F2QpvXW_T_h;H<)EcI6>qebWnb@FpEWD=dCs`6C3(2X?w+80I%oM zRP(xVX2A@@YJX>R3VoJGMx%Rl+GCCh>S{Yyb0gc63Zah7zDfbh@=Q3AtAu;CVA z0I+DpT3~jbFm<|l)&5}C1iOjrrBKt}f{#~MNyzLQJ3)?f_u&D($EIa=ukW3VMp%CT zTxGq^;!^31XorAWJOg0W^f?oWuo_M1Z~TZ&5q-fGZi2(Axp8nxbDzz}YiXNe)}7PG zXH(JpU7mr|%0Ayk>gf902o9#FH0Prix}vI|uyX4Fb#T3A#spyCVPRkqVPU~9GHwd^ zy#p*f0v0wR4kj)+1?2-aF+3_xd}?lSDR2!(23K|%1lXhb`K2x15YWG7XWvl|yBH>4 zk~%<-4&D~k^fld<#)($ulFRWeXkS-fQ`f{|bk$Ay`Wbq5*4+H!(=AQc($eSM)1_0S zJ8PrSwbWJ2%*_6=WQvjd>Yu))X)_vD8d_J$9H_=&3@Iua-?TFOG~HuaBHT^jB3=Jl zKAqH@%~xw9A*pM}ifsOBR_aUhoFA@zeuPAJ@=fUw&^OeMM!$0kRpIYwQ#$#3YyE&OYj_p?d zqYiNlRlz75*?b4jx@6*&I6Y2wZO(=c3XZUhhmXeQK8RRRHa0f4R57zKDk==NX@79w z^VN1__I$jA0iqPxy@h@116K-r?QzY7UMGeJ%aW5kZXYjAUN`TrD48exK;7_V=!IPz#>YnJh`w+NkBCD6P!sSphxfzEH=H zd_pr6O{bBTX!cL~R(RIZWRSvogcot9Z<{pKy9nba#8}zaGV>cOk|Q5k!Y`^e*KwUB zybQ%aCC$sY6wVSg&%DDI6kfc;nYjAZpq%dmr)07G{oxc{4U5uD@A6G1TiAXkb3VpK zrU)Jw`my*uhWg4+yhxb}*0N*SD(@;KG&&)Sp{i#6?>%KEH1DWv3QBo=T3;ftea2*k zoq>chs9Rf4!!VXF5Y^@i*&rJs^!-FtG9t7{!27xzCv1(Zyq@5`8m4yD`X@(=3CyP@ z8QdJQrLS>D%b4u5)a~nd)%Cb_At(r{-0N1hrF#NNkYGW0Gcgy;I0 zjvz$(euB%pRY;ahqiYg(x!mmO8;7aq`o5hNfk4m*Gr(P|Tv`05^h zBCM~9K8WAP8JQO$sPF6MD%I?XTC<2@Xx63xdv2q9=*#m+9&Gp8d+zEQ_IuP zB1+BD%_UkvP_XR$0>^ka8b>1wKfInxD0;AtGlaSB8rZhIT(M>1_=a1>`-B%<*5)6Au9G@HP9|rC`bi7b&pj&lJx_o1KAd4##tbg)xlK6QATq{!1l4Z$yRL_O+i}X6n zkbYB-G6|yww9B6R^xqzb^?ZI;cvVr7GO^8HyU%4i%L;s`yF&Hk%eH#k`hJluc+Id? zFb%!U_KS1ls)x4>?JB4Inku43?S8%uTd{INNsq;2MvcMX*z?p*-kr2muSrj0X0o}> zBfkfpGR{}tgtk18Uh$NPoR>lV1P{LC?9@v5zG{!^M-J9U|& zw7Q))8E}5Ju&Ae+s#f)(wp6Q~uYWxBfVJsWzBc64ie_3_;)!t81?|HssrrqILCk~= z!3TBsCfI3lu>DH95Y-}w6^l8n1_r(oB}KE3w^7uc4Cz|G9n)Qt6Qx|)9PB9Y<3QA- zym*@Z1)~T}WT@_Q9L;9aJ;uAQUh1oz<-|;Ljym90Hq>~hRTTlTPSu;Y_Umd+ge`YNMGC$DOgTSi<_A)JlOP@PKEYZn8Jn2)RJ zXtBj`%f+(Vb`LJN13iU2n)7#pLnB5`Z5UQ*$>u_I8^pREjWRdIP|&|%fVMupq;NcF zYBP|brI(Sj^iZ}@8Oi)68f+UYWOdb50Y)r#@LdG_h7J}H1`hV-`!}$_z+%B+VpFh_ zi^JnkvT;b@ikUh;FymCycp8vbcN?|fU|>XHt^srRA4)HVhfaMSe;+xqJ5_xaaaLNH z(4Jn$FkbWKV~wLAd-=Fby%C2rkpf+OjiZ-svrw~5Jd=Mv^<4BrIkU6E^Rx9Hl0;2* zJdp@SUc#yJr@6q0Pf zE11*0O4lhH>=%iqij?@8MJ72Yn#v2;9dCzW_;V&>%R0Vyc?-9HzjNb|GMU*EYO_Rw z8JE5%5{K8o8wEAnMC*FOdBMBbAD<7DbYCi8slNI#eJtAO|KN;Fc((V5`wRDhS$}ok zk<$~&W2Yr{q4#k|nBm_rQ_PRC=m#a$W6o?Nh~zPe4GE+^Zen}cpxyaYTD5h2)*u&e z9xrJ`3ec2@4 zwwrCE*adsXuw^R^>*2tOxqrOoH%#Ar!`Qm}VOdy~INzQy*}tt#oiqO`)we3;FS(Xv zFM78GL(W+Rk;JT4iA7Fyr)Di>=!d*tlu29*OITY1*N1xB9H~O-?{_xeJfSCa=1P*<^X7K8PZ-VdhXaZm(1|yU>IuHR-MwJa*;e+)JaNt%fBR(F7e*C zGvWc!mJzh*qEUu>IK2Bio!s2M&^yf?|BvCA(dY@Cr42@VqLW@v?yN5#ISa>v&d&PN z*%9Dj;BWUH{&IF4aq=5qhZj?K#(ltP8UT9xt*gWS;p%-JT`MgkKVF+uSypCyo~f@V zn-ug|x0mMc#vgoUD7@lll<>^17%|4x^R7`LoH!Sll*lx3W+cd?JC`zwiqB_yPRY8r zGEBifp%6uHYfL;cAhN;3gqv9KV{{>z%FE*3!gC~V-uTzg_w-8JzK>SJTEabS#!UKPaAsQbwQ)2YJ=W%spW0x zHDIRs%$MD)$CH}NR+R3D$s;IhRs{5rwT~MnmzfwfgzB;n?QJkNsKD8sZ(M>RGHh3$Ugxx>o z&JY}KrT+eiNFZuvfYv@S-8#X>o|Y0jp^aNn8-G8RBZ&j5X54X0v&n+CN_RE? zjOttjIh^d$5UA3xBDU|THmu4kvczk}kfnl?|h zI1rsR$YEGBDecImF72qmGs(Sj5csU&|crr5L?rX{GQYr8mnEh!B%R+Sy@Wj43J3 zVNsBZ&=-^##4YXB)g}5@+`W6g^#q(XvfxV%91I*XA_6iJ%I#JZxbuVs2Sx~ZOdLva zbyH_@4hge>JcI|&>w0k6IK?!~UFx|sU7zJouHaEgT0D*KJv^HF`MQHB3VRJmDqHub z%Ge|jcb4NmmPm4(!#5gZ_JA9*WhvMtZiCLO7T0)}^%F~asW<7tUM=xz4DX=CpgMX| zS0ZcE_n*Z%t+^63XjETq)vb<(kY7nZEj67l`cXC1jQPP&6+!U3+oDa}$TNM<>hhh$ zp)qvQQCnAE<W?ACz4UbQQ+j1`P~CU=Syo@ zB*xyJL?GC*CZlZe(aJP?7G$7HpY**t`b9$6{)O-t6vhs*gmo&zI81na~yU=P7+s znj6!8j(7@Jy`>dX!HqQDDwuAgTCezMPeUm?6CO^wVWER32-COCQ@1P39W7fa3|tI^a9Jmj~?5b-AQzn>3=e zmN7ZTxd(Yy6t$;Rmn#lEGHkMbG5Ey|!7N))A#*dtYB-6Ry)t$9CGMR`Wwx3e1SfLm z@n_atkxITgS{Itz>`?^z>6!S7#;JuH_&a@0n%e!6PLNUcWHsDff!A}qL5xJ=&%NtB zn!gx+mp&*~)iOEMj9dTg>HO|KcS5H_x)gUmM$nU;T9js|Hu(9N@^dZUqWKrTrrKZT z>AH2DztZa06xc7=^SzFLpkPA!-ngu~Olb-08AKQ@oyNO(rbubV<9%Pe>0_cwoO#;- zt?9}I>OzTk_{J=ZYo{V{`)a$%jCh?tLrlZwD4sS>}aZp=dkZSYj_wFQz4x)*ZD)iX2$OA z&Qe9a7hXN4HCzuVPf~UN*79AvQiQDJ;9d2BO5%VZ`x!s4n!>Ub#wyKm_j2#D7ivS? zk5Ipg!`7Tc^u)LUFz-_>r>q*#2sr&cDN zrl@}jQzU|2Q={G$5xsc&k=Vef%Qk7Jz;?bwy+FI!nGv?UW0FQ77v6x4S;0YY^j?4( zTN5>!ZpO)IPzjbsMP!DRahb?K9;BGVqEXw8hX0h9xtM;ufEQUJ98Ly3U_ih4oYw+L zsz3{yL}|R@?Q7R3k0mR#gm&=qrbNbGPvJR7=I)x6NGhKP`oB^wH=7Qp)?_78R}#A; zh?j*x)0oBtedtXqjcSf%ZH{E-pqii#@1*@$nZAN29J~G@D(=%<3x_1b2AX_gUCFdM zo7BN+FAg*h`bl=4xA>#daSo5LiuHAA-qd>>C_{vr3(8t+H`0Pe9ILry%3O0i2vyu%cN3-9rjVeSk>rZJwRL^;| zh4SAS?OV9OeJigu>vQl9IMUiu(s!~isK$A0F)CRrN&n&pb=aW_P9R!GfYcz_S8aNO z3>rKPGq5iMDJ9;?`8@1|}V^%UkZ^9vfiV^B#M&MI#Wjh}snzT#6n zAFz?R2qOht1GYt`H8@H*{%hV8*!urfX{^i>wi$@>YPhO-KJ+V!9;P0QgjL~vH zX?U&~&}Z?p%8WJ@ z(XjG@gSmZ|vI~KnxZ?5(>geQ@LT3L`|3~$Dz0>M#w!@F^PR?ham>XA^`y=RhJGQaf zGjl)WdgmIIgBSOr!qeV&nh#*)%n|5|e1R8l!)1MDTyMP}02QcR) z*}EODXlXD;=?*xAdh=2%OHqnem+zqx?&f4ElTp-PFl1Q1$8AaO*NS^Twq!AEb^b0}ynIS7nM>+u z*y{4*HEt+j1BiB6zp}t2S9qo~?XrG)Qyon~Bt=L$zY`hALTd2Nu;mIengY^2u_u2_ zKh;wAoqr3Y`fk`6c+LPXu+SMqC^1wt8aV~>ol8(vM~DP69h+!>%zq>#j*Rc7)?B7Ga4ylIuOxIM>W3p-a_lp!$U%IW)9#bdZxA^Dxt zYoJacKKq7RX9o4VI(PYUr_d;=1Elg&oB~(liLWA?SCLzpMv+($GInjq#9sL|Kz1Ak z*U7JNPLnFd-TZoY-d5YjDZRU2EX0>p^o%iSwr>~qrB-!_u1`M7qRmFDiv%vh@N4TY z{OKKrJA76lFQ}{gl*ziehxi?HA z$44HFOTXI8tb(d@Gch~k6Y5#Q0YBfa4WUS)Ie49GfLb&;o3smioDufG>`C&8*q4?U zV-N1aU>FY-a2`XXQU|J)ls&^x3Ut~VQ$lD?W?KT~6bw$o4&UKg;_tNJNnOb)Sk^T4 z7?ha7xYOM8g-I%b5_xCk$#m%C7(KAbClI`xSz77n>4*F8IoqJg>^= zXp>V#W7hd|@sk%kqLn_38~AWp3Q*;JHJbG5HmMOiIhHfoG*YKc<<9 zX2vR8JqYfESbIc~9a@G!%eHxZ2kFDtYzu8z2Hsk7DX%k}6Xw##q(#B-m*+~#Eb$Loo<~H^mZx-=3pP{RIn}=r?xSVgP-V>VX!1mfFL*ZZ{mxb_k z3{b9QF9%j~apC3acOf+z!Z6hlUeS1ahbs^2o2;EYDmRvv)?)2{!k~!;UueG6v+Kaf z$vfWUQB|8`!FSJm(3~8rQYN~FtMiaztxgF%nwM$dw?~R4T0n7E% zj0%SnWHDclJU$u&gr+HcsV4V!k#$LiQ(@e1F)=7pV^KeJo`17huLvQ+u&zU6*+sXp@RVRFETrzZ3M1Q5Xm zo2R_|OtT%z|D$8CGCY@>_{vvN5Ciy#{=<)OC_L(Z@ykW5q;GezzlSv7>G_T{A=L5- z4{@w3?ZJ()4ISrNR3hFhCo#5l4I_)Js3G$R_BWeBX^@m+V}W`5(uOv-#W^i!`Iw8Q zx6WFykud27XOnX$q^417(otBxT*f@3Qc|~Dh{RVxBf@R@{=y>}o+7lI4I{~WetoN= zruP{35bXPd$#vJJ7=qQlK~oK1y`DyV9$+7^Ut^GiUQ?fJmFiTfTF2)d-dI<^$%;r45_$sd}^6 z=PYCN-zb=?HtB07Vn_FqG=5;pwsfkWh#o6uT?4~-BC}D$dEaRL46@!oNrlCI$hcLe zDF%ZUFt3MU+iVyR=BCxd&@#&S*@%XI+1&Jg2@I=|*d}S0rjWM0g1~MF*U%m8AT<6Y zX-K(ojv3jYcD;7=Mf9fbOGR{SMK& z1!i*E#xGi!FY-~I&8qt@;t_BfF<+A3Ep8cO8bLoLWoHZ?^PE)weC``WbXrTQSOK64{32lNLH=8*}rA-4xW_pqI45x9$U2h+PZ7Ub1g0272%6eK+8#5SM||0sLBz8$?C*#agYKr;vC`MH=X<`K+Y4XOgsCHbz4)- z9G)V}BaWKJcU}X~PD!dMre|_6&Egcp7{`hf7&KE#0Preb9RBG*&>d}ho^)cr6hCr# zD!8%e=6w3vjq%QQ@~3ivxeP#IhRutYzTzmb@5Q2P0pz85~FHI}cBXR2l)(M&!rJl#*v=iM%*7@lH}zr$l>$Y!@*KYqWEQ zg>sE=N+pEp>#IDjfxGji0q%pHV62Dmakg)5k!UwM?)jdVm(~q&C8OOFS^A-5_Jkq( z=$%YqT=j>NnMK3G9)pO%MN0*1^~5Jjs{F}&-f5{u{BWtM0ZZ<&WKW-9G2^BWZ&wZ& zFPxLvrdG%oUjx8kL?((~_goxzEZo^dq*S*UwheX+l5ODkiXRxc=1<9IT}U>#V0E-@ z*!4uxk*@(TccsU=$z8!?b})CnJ;4BOa#u_+c?Gwo0>H$TP3-yPidx=n@_KW2fe9v= zU57hhawySQX5x^Or=KJ9T7K5UyoUGG zkzvYQnF(doQ+d=D;Sx*eh()prqM8Ncxt2DjJ;S1Mmlg5&hPUO<|*Up%8-IB504kIDy+*uUzB{lsC9?QHFYJ$hk*$u zYxRRzM(rIU9^$+x#gqyiXT*B}aM}}kL}7epJZ6R{z2w6hXMtrl9|&MEHZ!~J6C;z$ zw!7a19bO<=j0rGf4o8M_0DM_8{YR{$Mi`kZySZL z^9*#Nf*q%OyOv53iELz+M=Y(Rt;Nn6K)=E=F2x%LN0`Ik(>(G`5ARKB5JIVA5tU(d zAEtOHmge0`69PGJ8 zBa~irBrOBXK4RYX{Lrgf%Bq@*i-<(ePt2xRrd3#NQrbj%K(t#RPg?T*HQQim>c&q} z9IwKcse=*uU>FEcc`Bb($0?v}UM9x%ZOXM>KAr5!*&%=NJQR&|H!-4LNha**{DF=cW4LW4rD~zH2^s76=8?-DaXl$0``leT~f<2SytCYtK-L994)Vh8FNzFiyo1e)W+I{ihOME zVyDYt;DB52h56PAx9PLKN_{mPCxwbDCN$^C9r+L0dtRYUfzVDu^F~m}7x&Va)iw zN8w5&(h-7N5?B$2gdD3=pK|gBnZ=nt?oS$0izVfGmPrM&bFA;Qs9;7g-E&$03S%LM zhtoTd;C4_2z5^~#z>f!UhkLzA$;%(e*aqA0HA(9T-v4&MHxRk<*BSta0)XE|-j-i$ z06E*u8!WgCh=Gg1?HX`%sDt@~vFHg$TlxW`A^1MZD>i_^xJ^CTbJ_7*xP?SqK3Am_X+T#1ijrrq4=c@G>7JFvB<_hGA&GR{#Q zv#gO~qKTr6WRwZHb~TDT@v6k$Tq#Id2PUI)elRrXt?N%BAxx3(#zvPI%!~h2u!5;5 z4XI=GIJ9C5J>Lyl#j%)MAUev;_{7=6D^;7BfIf;E6JsbbzES`iGk(TmI2TKmC$~e` z%H8S`KOEr9ySy8l`X2SrWpmZ-&`TnZ6Ly_7^d9B z<2onREx$#4*wZ4nPNL|wH(bbr!om>1as(*VXR~`1^_qxeQIkB*%X%&U3SR%!Yp(_@ z!~q4wqd`_QzwX?D4ecIgn9EHQtg^gAbIe$;3=S<8SAbFXTFhqrw?#K?_EYAH!J416e=e<|SM@jJR zroE1t*Fdeda|jj&9coeXd-<;87Q6pleD2M5;oXIQ^};@!UB7(pWZo*`C^ zW2~&anKTrNPqO;VE0AciAEg;nj$0bes$C!Z94F{cjVJ$G{-l;q5JG#1D?Yl zrxCpVo@2qH--19Y@~CG}fW>ExYJ9D`feFc^Z6r8VG+tH)(saIK0z#8zEaXrGFnl(t6F9fuZ8g-YlInIHGnq`Abm<{AZe!c z!T-@~ft1|wX^RS0m2MloaD*rn>974C$KIhLtoFaMg2n5SZ^T6|F08G6o(-xhEgxA6O;uZDR+zr zn;4v5Hfl)>4lH`C4cfVbGGhc3Ra>+Ktas6_QYE8}R!A?X%JkFQwibBzzK-U*t-Z#S zU8sIFU-v1$XhJ}MBd^HAt}c-$vPjk%yNy5;TtjGY_P}m7!Tw4F&dSZ(y%Mf;9o|WvrzgJAdpk6b2uW8 z!B4$vcuXJ?MZqArqws3*u_;vMt3ZD-BK14TH$HbW&8{e)J==Oa){xJ!a`zC8#ju6r zJ5wrzE&UOL`eS^iDXys6@lV$B@K~xGX_d`BMN0-LKVJDU9Uby6-t&st3S^~9*`)W!h$(NmV}+A zQFkXutMxIup*DSh8ct;Hsyg zRhP6O$p&mTbFi4=@Otp|>h}@%nJwUIo6nsl)~}{_-4U7X@VPRm{#jyrMIh z5mAkZn%A13C|g}IW);0MEHGwBx*7vNwn7RjlJ{qjHB?-<7iget+X4HYyv9*tJWMr?D$CH+NYH9ow0^Fy8FnUEm2W4H+|5A zyL0T*E$D5k?ecwkX_gL&T!W@Rsq>H{OI)Cn0w-U5tDeJPj+@WKY&fMNEMwA z`|L`Z4ER>|DZI)>XHhTmZW`0aUXi)H%13!9P{DdPbgQ=xXy(xwq+txF?L%c$cEZ;4WRKUI*F{2s@rj*Yj4p99(5R7=uO_ z!A1H&rp6?V0`wQ#`A)X5FiW0fj(Phr!Uv0d^-ePRutv2P^G-xcSK%GZ#VfUp zX6or|gvvx}{l(q}96S^3_*(I4?`w$vYz)_ZnDaE3CQo7WFiRu*-nMV-+w@f}Wcl2h z8UYd&X;Iao2XIFD9xa_FQVj*lN_7v%8-(R;{3ng_S1%MXN1nAUQrKfJ$(|#P@`V6JFvz@}G0HMGsIgnS0k6k&3 zHeBV@?LA5unRd^NA24In8yT@?>hbjrW%Ek7*BiJcEg_2@En64FqUeAZLRJ?My^yx!ve+@YE9Ire0_SzECe~V1?%SP&&@v(L;lj`*hdH9~}p-5pWLiY-*{+Wl1ojkJA z&a9tkSlXRpt(PN>cGO~a;l3~T2XiywALn|xI|!+Y+USTw$`;|dks2#Es1*pMkV(il zSQvI)^%QZ+M}um{n%9-3o2}RjuizdLSqcVZI5b@(lla52ILLk`5w2y-sGJ<|nQ@RJ z2vPR-rw$+C!q7}__bgk(tZS(vVc@dgCGT@{tX-_{ zP#9?*G*j-#AI740im0czhPUyv#9vXC=D-WB*Pg?jT7KuH+N=|n2rcvP{=@!nFiCxs75-pqRzXA`fF--G1RXC zcD`h^b>$dFM&Ic2mSua^lm>jnZy|&7MbAt82;dn|kKEpHd*QhnQya(&!bozFpVc>{ z2Jhko5POc_kD(lU9Y~tD=E2r@tni4KU-C)I)O1?jd%cvJw-4GMBicKCOTIX0UR^m2zg8v)E|Z%O|wc@zbnsa zE77FM3FP+~D(E7#_v&G5YeX=dCIIyDEC6E&x{)lW%07VoR#$(P649gH^3yW=tLN>r zv-K}xB0J|2j}qjonMO<-B00Iuum)MJP>{QXKR;CC=&sQ{ZHnemSZfm=wp7$J^oLy1 z6}kqmqie5kC>xaMepmJbx~&Yt`zuP%g4?t^db!JzSS}ho%ld7CDkPCfKFwNv>cQm6 zCoGyF>~2zHZaPw2B@fOes2!6Tv<=$G&r{GUaR1!HBF{34OhSyii1{V2INx@WzDBmP zOd#4}HBa}jzD<#?!a-q$-iApNfiNwHoq@Q#U~x9T@2x_QdcLu;^TxwB0sXkJ!EXbC z`knBpC64QIdGp+(8edH+*9WsP6qYayteVgSTlOI02(~SMaGCX+*t*^{3qk+|T zf+FJ%sCkoT7h&5r2PqON3LB5TI3U_6&#SZt04~MhJ8sC5hAn7p0Of@Xfapa}it0VIl_CCBo{o7!aQ;ou|EGF=MkM z$Vq$B3Xqr-YE{MIV-OVd>rZYt4?KMLRhPOwWk6@uNGd-#X@GD9y-d=x8BTYQf*5{| z+o`2KfiZ^3?D#Q*HWpzlw}&`qRk=89CSMfRupGY0XC{iyVJC%{UHI+vdr5jGgkREh z+IZfeeGN=yGja3Rz(QNp@boWQ!hsz-pC#^N1ru_v3zemk_kCw+wJ;|pldA&V%zO9r zkgE<{vB_&Ykh}cfQE}Ezmq5Nfr98}X{yJ46=jWiD7}qY~AHiVcNB&5!r)ljP;D~xi ze-_Zro~RtzF>QEQpA_8us9TmZ?jVqwKt%3?b_*HIqs`zM`p`#Ci`#pB4!(>Axuofj z8^U}D=ILSf)SjBH;lez=1~7wJS}YqbloeZ&YE_4(sKw!O5UoD+Jz2ukmDTMam<2ZS z0Z!I;+i=1^o@xwyJVDoxt$b9%oP$+AQ}i$)){D*j>09QD{`v|hwLGh_#ub-s`HLal zFub=3d|XG7_a~6qxKo5o`k7eY;79KT$9mb}1t00EL=2)eVe-%gf5*o8boV1&czE~&{&ZC^98j1Bo?UP?JPdKFK&#rQhoJ7)Mvx3qmb&KWDP zfZ2v~RZD-j!%dcAt1Y?cAiXDE+K)Nxhb5j!-v^ND)R+2u@@HaEJfwUl7-jAFrhC~( z;Y0vYxmn{NtrJcvuVPmBPHQK07JmW9r3~87cn`(+P+VFgHI=}_48T@w`=D{$Ek5&l zFW6n-?sICd2#Sgpd;qSQ3bLXPMgy3yR4*L%Plzf-nb{c?-e^pv5}Bv_-q-n{hV$fD z%b>Use=b4b$AdEMbeI|;n$xyI5RsKly=S(92vmX#-+lXi} zR}QO{fV0b(Dx(f-VG|rF%na-n;}bM73u1(Ew!`>#jNA@KB(lY8A$+{GqY~W3+T0lB z5V^MYM1J`}#)x1BQ~>iSBd!w*r7m)q`Rx>teL+!K3u7O}h(<=vUiI-EazP4A;}dI! zKG6e9;V*bDCg47>6^DboN;6v-f!G4?fqE_>C3I8@KQ|CC%k&R2&|b9dDNC`I;#6>V z#zn&&LjK5c*#T*Xyx9%SgQNflg!G03qf|2{;uV^rBi_$~0X;4We=^{4XV&9*;~7%K zE557U3$SfUpISapB7j|`E%rRAp*x!E~T zz*O39v2_^Ryjdf74^ANB_>F0rIE)@#*&Xtvg0*_K;|f#U!%_Ez*b{nrgyODXjeX&n zXVxNVYP?1jpEC?iR9|`Kl&?4TsbL+FWjrI^SNwzj0ApzM7_WMY=~p(fT~Dye5cQR`PkC5N kU)`Y*o53#}p?FJ5!Yg(z$AYov9iZ6tfDd*wj9*j#*;Tn78UO$Q literal 0 HcmV?d00001 diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index ae34c3e0..cc4c05b8 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -1,665 +1,665 @@ -import * as fs from 'fs'; -import { imageSize } from 'image-size'; -import { Config } from '../../../common/config/private/Config'; -import { SideCar } from '../../../common/entities/MediaDTO'; -import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; -import { VideoMetadata } from '../../../common/entities/VideoDTO'; -import { Logger } from '../../Logger'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import * as exifr from 'exifr'; -import { FfprobeData } from 'fluent-ffmpeg'; -import { FileHandle } from 'fs/promises'; -import * as util from 'node:util'; -import * as path from 'path'; -import { IptcParser } from 'ts-node-iptc'; -import { Utils } from '../../../common/Utils'; -import { FFmpegFactory } from '../FFmpegFactory'; -import { ExtensionDecorator } from '../extension/ExtensionDecorator'; - -const LOG_TAG = '[MetadataLoader]'; -const ffmpeg = FFmpegFactory.get(); - -export class MetadataLoader { - - @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata) - public static async loadVideoMetadata(fullPath: string): Promise { - const metadata: VideoMetadata = { - size: { - width: 1, - height: 1, - }, - bitRate: 0, - duration: 0, - creationDate: 0, - fileSize: 0, - fps: 0, - }; - - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification - } catch (err) { - console.log(err); - // ignoring errors - } - try { - - - const data: FfprobeData = await util.promisify( - // wrap to arrow function otherwise 'this' is lost for ffprobe - (cb) => ffmpeg(fullPath).ffprobe(cb) - )(); - - try { - for (const stream of data.streams) { - if (stream.width) { - metadata.size.width = stream.width; - metadata.size.height = stream.height; - - if ( - Utils.isInt32(parseInt('' + stream.rotation, 10)) && - (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1 - ) { - // noinspection JSSuspiciousNameCombination - metadata.size.width = stream.height; - // noinspection JSSuspiciousNameCombination - metadata.size.height = stream.width; - } - - if ( - Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000)) - ) { - metadata.duration = Math.floor( - parseFloat(stream.duration) * 1000 - ); - } - - if (Utils.isInt32(parseInt(stream.bit_rate, 10))) { - metadata.bitRate = parseInt(stream.bit_rate, 10) || null; - } - if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) { - metadata.fps = parseInt(stream.avg_frame_rate, 10) || null; - } - metadata.creationDate = - Date.parse(stream.tags.creation_time) || - metadata.creationDate; - break; - } - } - - // For some filetypes (for instance Matroska), bitrate and duration are stored in - // the format section, not in the stream section. - - // Only use duration from container header if necessary (stream duration is usually more accurate) - if ( - metadata.duration === 0 && - data.format.duration !== undefined && - Utils.isInt32(Math.floor(data.format.duration * 1000)) - ) { - metadata.duration = Math.floor(data.format.duration * 1000); - } - - // Prefer bitrate from container header (includes video and audio) - if ( - data.format.bit_rate !== undefined && - Utils.isInt32(data.format.bit_rate) - ) { - metadata.bitRate = data.format.bit_rate; - } - - if ( - data.format.tags !== undefined && - typeof data.format.tags.creation_time === 'string' - ) { - metadata.creationDate = - Date.parse(data.format.tags.creation_time) || - metadata.creationDate; - } - - // eslint-disable-next-line no-empty - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); - Logger.silly(err); - } - metadata.creationDate = metadata.creationDate || 0; - - try { - // search for sidecar and merge metadata - const fullPathWithoutExt = path.parse(fullPath).name; - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - - for (const sidecarPath of sidecarPaths) { - if (fs.existsSync(sidecarPath)) { - const sidecarData = await exifr.sidecar(sidecarPath); - if (sidecarData !== undefined) { - if ((sidecarData as SideCar).dc.subject !== undefined) { - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of (sidecarData as SideCar).dc.subject) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } - } - if ((sidecarData as SideCar).xmp.Rating !== undefined) { - metadata.rating = (sidecarData as SideCar).xmp.Rating; - } - } - } - } - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); - Logger.silly(err); - } - - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); - Logger.silly(err); - } - return metadata; - } - - private static readonly EMPTY_METADATA: PhotoMetadata = { - size: { width: 0, height: 0 }, - creationDate: 0, - fileSize: 0, - }; - - @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata) - public static async loadPhotoMetadata(fullPath: string): Promise { - let fileHandle: FileHandle; - const metadata: PhotoMetadata = { - size: { width: 0, height: 0 }, - creationDate: 0, - fileSize: 0, - }; - const exifrOptions = { - tiff: true, - xmp: true, - icc: false, - jfif: false, //not needed and not supported for png - ihdr: true, - iptc: false, //exifr reads UTF8-encoded data wrongly, using IptcParser instead - exif: true, - gps: true, - reviveValues: false, //don't convert timestamps - translateValues: false, //don't translate orientation from numbers to strings etc. - mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged - }; - - //function to convert timestamp into milliseconds taking offset into account - const timestampToMS = (timestamp: string, offset: string) => { - if (!timestamp) { - return undefined; - } - //replace : with - in the yyyy-mm-dd part of the timestamp. - let formattedTimestamp = timestamp.substring(0,9).replaceAll(':', '-') + timestamp.substring(9,timestamp.length); - if (formattedTimestamp.indexOf("Z") > 0) { //replace Z (and what comes after the Z) with offset - formattedTimestamp.substring(0, formattedTimestamp.indexOf("Z")) + (offset ? offset : '+00:00'); - } else if (formattedTimestamp.indexOf("+") > 0) { //don't do anything - } else { //add offset - formattedTimestamp = formattedTimestamp + (offset ? offset : '+00:00'); - } - //parse into MS and return - return Date.parse(formattedTimestamp); - } - - //function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp - const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { - let UTCTimestamp = gpsTimeStamp; - if (!UTCTimestamp && - gps && - gps.GPSDateStamp && - gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available - //GPS timestamp is always UTC (+00:00) - UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':'); - } - if (UTCTimestamp && timestamp) { - //offset in minutes is the difference between gps timestamp and given timestamp - //to calculate this correctly, we have to work with the same offset - const offsetMinutes = (timestampToMS(timestamp, '+00:00')- timestampToMS(UTCTimestamp, '+00:00')) / 1000 / 60; - if (-720 <= offsetMinutes && offsetMinutes <= 840) { - //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) - return (offsetMinutes < 0 ? "-" : "+") + //leading +/- - ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' - ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes - } else { - return undefined; - } - } else { - return undefined; - } - } - - //Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section) - const unescape = (tag: string) => { - return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { - return String.fromCharCode(parseInt(numStr, 10)); - }); - } - - try { - const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); - fileHandle = await fs.promises.open(fullPath, 'r'); - try { - await fileHandle.read(data, 0, Config.Media.photoMetadataSize, 0); - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } finally { - await fileHandle.close(); - } - try { - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - metadata.creationDate = stat.mtime.getTime(); - } catch (err) { - // ignoring errors - } - try { - //read the actual image size, don't rely on tags for this - const info = imageSize(fullPath); - metadata.size = { width: info.width, height: info.height }; - } catch (e) { - //in case of failure, set dimensions to 0 so they may be read via tags - metadata.size = { width: 0, height: 0 }; - } - - - try { //Parse iptc data using the IptcParser, which works correctly for both UTF-8 and ASCII - const iptcData = IptcParser.parse(data); - if (iptcData.country_or_primary_location_name) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.country = - iptcData.country_or_primary_location_name - .replace(/\0/g, '') - .trim(); - } - if (iptcData.province_or_state) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.state = iptcData.province_or_state - .replace(/\0/g, '') - .trim(); - } - if (iptcData.city) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.city = iptcData.city - .replace(/\0/g, '') - .trim(); - } - if (iptcData.object_name) { - metadata.title = iptcData.object_name.replace(/\0/g, '').trim(); - } - if (iptcData.caption) { - metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); - } - if (Array.isArray(iptcData.keywords)) { - metadata.keywords = iptcData.keywords; - } - - if (iptcData.date_time) { - metadata.creationDate = iptcData.date_time.getTime(); - } - } catch (err) { - // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); - } - - try { - let orientation = 1; //Orientation 1 is normal - const exif = await exifr.parse(data, exifrOptions); - //exif is structured in sections, we read the data by section - - //dc-section (subject is the only tag we want from dc) - if (exif.dc && - exif.dc.subject && - exif.dc.subject.length > 0) { - const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of subj) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } - } - - //ifd0 section - if (exif.ifd0) { - if (exif.ifd0.ImageWidth && metadata.size.width <= 0) { - metadata.size.width = exif.ifd0.ImageWidth; - } - if (exif.ifd0.ImageHeight && metadata.size.height <= 0) { - metadata.size.height = exif.ifd0.ImageHeight; - } - if (exif.ifd0.Orientation) { - orientation = parseInt( - exif.ifd0.Orientation as any, - 10 - ) as number; - } - if (exif.ifd0.Make && exif.ifd0.Make !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.make = '' + exif.ifd0.Make; - } - if (exif.ifd0.Model && exif.ifd0.Model !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.model = '' + exif.ifd0.Model; - } - //if (exif.ifd0.ModifyDate) {} //Deferred to the exif-section where the other timestamps are - } - - //exif section starting with the date sectino - if (exif.exif) { - //Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date - //Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone. - if (exif.exif.DateTimeOriginal) { - //DateTimeOriginal is when the camera shutter closed - if (exif.exif.OffsetTimeOriginal) { //OffsetTimeOriginal is the corresponding offset - metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, exif.exif.OffsetTimeOriginal); - metadata.creationDateOffset = exif.exif.OffsetTimeOriginal; - } else { - const alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); - metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); - metadata.creationDateOffset = alt_offset; - } - } else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence - //Create is when the camera wrote the file (typically within the same ms as shutter close) - if (exif.exif.OffsetTimeDigitized) { //OffsetTimeDigitized is the corresponding offset - metadata.creationDate = timestampToMS(exif.exif.CreateDate, exif.exif.OffsetTimeDigitized); - metadata.creationDateOffset = exif.exif.OffsetTimeDigitized; - } else { - const alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); - metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); - metadata.creationDateOffset = alt_offset; - } - } else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence - if (exif.exif.OffsetTime) { - //exif.Offsettime is the offset corresponding to ifd0.ModifyDate - metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, exif.exif?.OffsetTime); - metadata.creationDateOffset = exif.exif?.OffsetTime - } else { - const alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); - metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); - metadata.creationDateOffset = alt_offset; - } - } else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) { - const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); - metadata.creationDate = timestampToMS(exif.ihdr["Creation Time"], any_offset); - metadata.creationDateOffset = any_offset; - } else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) { - const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); - metadata.creationDate = timestampToMS(exif.xmp.MetadataDate, any_offset); - metadata.creationDateOffset = any_offset; - } - if (exif.exif.LensModel && exif.exif.LensModel !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.lens = '' + exif.exif.LensModel; - } - if (Utils.isUInt32(exif.exif.ISO)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10); - } - if (Utils.isFloat32(exif.exif.FocalLength)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.focalLength = parseFloat( - '' + exif.exif.FocalLength - ); - } - if (Utils.isFloat32(exif.exif.ExposureTime)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.exposure = parseFloat( - parseFloat('' + exif.exif.ExposureTime).toFixed(6) - ); - } - if (Utils.isFloat32(exif.exif.FNumber)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.fStop = parseFloat( - parseFloat('' + exif.exif.FNumber).toFixed(2) - ); - } - if (exif.exif.ExifImageWidth && metadata.size.width <= 0) { - metadata.size.width = exif.exif.ExifImageWidth; - } - if (exif.exif.ExifImageHeight && metadata.size.height <= 0) { - metadata.size.height = exif.exif.ExifImageHeight; - } - } - - //gps section - if (exif.gps) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.GPSData = metadata.positionData.GPSData || {}; - - if (Utils.isFloat32(exif.gps.longitude)) { - metadata.positionData.GPSData.longitude = parseFloat( - exif.gps.longitude.toFixed(6) - ); - } - if (Utils.isFloat32(exif.gps.latitude)) { - metadata.positionData.GPSData.latitude = parseFloat( - exif.gps.latitude.toFixed(6) - ); - } - - if (metadata.positionData) { - if (!metadata.positionData.GPSData || - Object.keys(metadata.positionData.GPSData).length === 0) { - metadata.positionData.GPSData = undefined; - metadata.positionData = undefined; - } - } - } - //photoshop section (sometimes has City, Country and State) - if (exif.photoshop) { - if (!metadata.positionData?.country && exif.photoshop.Country) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.country = unescape(exif.photoshop.Country); - } - if (!metadata.positionData?.state && exif.photoshop.State) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.state = unescape(exif.photoshop.State); - } - if (!metadata.positionData?.city && exif.photoshop.City) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.city = unescape(exif.photoshop.City); - } - } - - /////////////////////////////////////// - metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive - metadata.size.width = Math.max(metadata.size.width, 1); //ensure width dimension is positive - - //Before moving on to the XMP section (particularly the regions (mwg-rs)) - //we need to switch width and height for images that are rotated sideways - if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%) - // noinspection JSSuspiciousNameCombination - const height = metadata.size.width; - // noinspection JSSuspiciousNameCombination - metadata.size.width = metadata.size.height; - metadata.size.height = height; - } - /////////////////////////////////////// - - //xmp section - if (exif.xmp && exif.xmp.Rating) { - metadata.rating = exif.xmp.Rating; - if (metadata.rating < 0) { - metadata.rating = 0; - } - } - //xmp."mwg-rs" section - if (Config.Faces.enabled && - exif["mwg-rs"] && - exif["mwg-rs"].Regions) { - const faces: FaceRegion[] = []; - const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; - if (regionListVal) { - for (const regionRoot of regionListVal) { - let type; - let name; - let box; - const createFaceBox = ( - w: string, - h: string, - x: string, - y: string - ) => { - if (4 < orientation) { //roation is sidewards (90 or 270 degrees) - [x, y] = [y, x]; - [w, h] = [h, w]; - } - let swapX = 0; - let swapY = 0; - switch (orientation) { - case 2: //TOP RIGHT (Mirror horizontal): - case 6: //RIGHT TOP (Rotate 90 CW) - swapX = 1; - break; - case 3: // BOTTOM RIGHT (Rotate 180) - case 7: // RIGHT BOTTOM (Mirror horizontal and rotate 90 CW) - swapX = 1; - swapY = 1; - break; - case 4: //BOTTOM_LEFT (Mirror vertical) - case 8: //LEFT_BOTTOM (Rotate 270 CW) - swapY = 1; - break; - } - // converting ratio to px - return { - width: Math.round(parseFloat(w) * metadata.size.width), - height: Math.round(parseFloat(h) * metadata.size.height), - left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width), - top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height), - }; - }; - /* Adobe Lightroom based face region structure */ - if ( - regionRoot && - regionRoot['rdf:Description'] && - regionRoot['rdf:Description'] && - regionRoot['rdf:Description']['mwg-rs:Area'] - ) { - const region = regionRoot['rdf:Description']; - const regionBox = region['mwg-rs:Area'].attributes; - - name = region['mwg-rs:Name']; - type = region['mwg-rs:Type']; - box = createFaceBox( - regionBox['stArea:w'], - regionBox['stArea:h'], - regionBox['stArea:x'], - regionBox['stArea:y'] - ); - /* Load exiftool edited face region structure, see github issue #191 */ - } else if ( - regionRoot && - regionRoot.Name && - regionRoot.Type && - regionRoot.Area - ) { - const regionBox = regionRoot.Area; - name = regionRoot.Name; - type = regionRoot.Type; - box = createFaceBox( - regionBox.w, - regionBox.h, - regionBox.x, - regionBox.y - ); - } - - if (type !== 'Face' || !name) { - continue; - } - - // convert center base box to corner based box - box.left = Math.round(Math.max(0, box.left - box.width / 2)); - box.top = Math.round(Math.max(0, box.top - box.height / 2)); - - - faces.push({ name, box }); - } - } - if (faces.length > 0) { - metadata.faces = faces; // save faces - if (Config.Faces.keywordsToPersons) { - // remove faces from keywords - metadata.faces.forEach((f) => { - const index = metadata.keywords.indexOf(f.name); - if (index !== -1) { - metadata.keywords.splice(index, 1); - } - }); - } - } - } - } catch (err) { - // ignoring errors - } - - if (!metadata.creationDate) { - // creationDate can be negative, when it was created before epoch (1970) - metadata.creationDate = 0; - } - - try { - // search for sidecar and merge metadata - const fullPathWithoutExt = path.parse(fullPath).name; - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - - for (const sidecarPath of sidecarPaths) { - if (fs.existsSync(sidecarPath)) { - const sidecarData = await exifr.sidecar(sidecarPath); - - if (sidecarData !== undefined) { - if ((sidecarData as SideCar).dc.subject !== undefined) { - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of (sidecarData as SideCar).dc.subject) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } - } - if ((sidecarData as SideCar).xmp.Rating !== undefined) { - metadata.rating = (sidecarData as SideCar).xmp.Rating; - } - } - } - } - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); - Logger.silly(err); - } - - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } - return metadata; - } -} +import * as fs from 'fs'; +import { imageSize } from 'image-size'; +import { Config } from '../../../common/config/private/Config'; +import { SideCar } from '../../../common/entities/MediaDTO'; +import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; +import { VideoMetadata } from '../../../common/entities/VideoDTO'; +import { Logger } from '../../Logger'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import * as exifr from 'exifr'; +import { FfprobeData } from 'fluent-ffmpeg'; +import { FileHandle } from 'fs/promises'; +import * as util from 'node:util'; +import * as path from 'path'; +import { IptcParser } from 'ts-node-iptc'; +import { Utils } from '../../../common/Utils'; +import { FFmpegFactory } from '../FFmpegFactory'; +import { ExtensionDecorator } from '../extension/ExtensionDecorator'; + +const LOG_TAG = '[MetadataLoader]'; +const ffmpeg = FFmpegFactory.get(); + +export class MetadataLoader { + + @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata) + public static async loadVideoMetadata(fullPath: string): Promise { + const metadata: VideoMetadata = { + size: { + width: 1, + height: 1, + }, + bitRate: 0, + duration: 0, + creationDate: 0, + fileSize: 0, + fps: 0, + }; + + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification + } catch (err) { + console.log(err); + // ignoring errors + } + try { + + + const data: FfprobeData = await util.promisify( + // wrap to arrow function otherwise 'this' is lost for ffprobe + (cb) => ffmpeg(fullPath).ffprobe(cb) + )(); + + try { + for (const stream of data.streams) { + if (stream.width) { + metadata.size.width = stream.width; + metadata.size.height = stream.height; + + if ( + Utils.isInt32(parseInt('' + stream.rotation, 10)) && + (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1 + ) { + // noinspection JSSuspiciousNameCombination + metadata.size.width = stream.height; + // noinspection JSSuspiciousNameCombination + metadata.size.height = stream.width; + } + + if ( + Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000)) + ) { + metadata.duration = Math.floor( + parseFloat(stream.duration) * 1000 + ); + } + + if (Utils.isInt32(parseInt(stream.bit_rate, 10))) { + metadata.bitRate = parseInt(stream.bit_rate, 10) || null; + } + if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) { + metadata.fps = parseInt(stream.avg_frame_rate, 10) || null; + } + metadata.creationDate = + Date.parse(stream.tags.creation_time) || + metadata.creationDate; + break; + } + } + + // For some filetypes (for instance Matroska), bitrate and duration are stored in + // the format section, not in the stream section. + + // Only use duration from container header if necessary (stream duration is usually more accurate) + if ( + metadata.duration === 0 && + data.format.duration !== undefined && + Utils.isInt32(Math.floor(data.format.duration * 1000)) + ) { + metadata.duration = Math.floor(data.format.duration * 1000); + } + + // Prefer bitrate from container header (includes video and audio) + if ( + data.format.bit_rate !== undefined && + Utils.isInt32(data.format.bit_rate) + ) { + metadata.bitRate = data.format.bit_rate; + } + + if ( + data.format.tags !== undefined && + typeof data.format.tags.creation_time === 'string' + ) { + metadata.creationDate = + Date.parse(data.format.tags.creation_time) || + metadata.creationDate; + } + + // eslint-disable-next-line no-empty + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); + Logger.silly(err); + } + metadata.creationDate = metadata.creationDate || 0; + + try { + // search for sidecar and merge metadata + const fullPathWithoutExt = path.parse(fullPath).name; + const sidecarPaths = [ + fullPath + '.xmp', + fullPath + '.XMP', + fullPathWithoutExt + '.xmp', + fullPathWithoutExt + '.XMP', + ]; + + for (const sidecarPath of sidecarPaths) { + if (fs.existsSync(sidecarPath)) { + const sidecarData = await exifr.sidecar(sidecarPath); + if (sidecarData !== undefined) { + if ((sidecarData as SideCar).dc.subject !== undefined) { + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of (sidecarData as SideCar).dc.subject) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + if ((sidecarData as SideCar).xmp.Rating !== undefined) { + metadata.rating = (sidecarData as SideCar).xmp.Rating; + } + } + } + } + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); + Logger.silly(err); + } + + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); + Logger.silly(err); + } + return metadata; + } + + private static readonly EMPTY_METADATA: PhotoMetadata = { + size: { width: 0, height: 0 }, + creationDate: 0, + fileSize: 0, + }; + + @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata) + public static async loadPhotoMetadata(fullPath: string): Promise { + let fileHandle: FileHandle; + const metadata: PhotoMetadata = { + size: { width: 0, height: 0 }, + creationDate: 0, + fileSize: 0, + }; + const exifrOptions = { + tiff: true, + xmp: true, + icc: false, + jfif: false, //not needed and not supported for png + ihdr: true, + iptc: false, //exifr reads UTF8-encoded data wrongly, using IptcParser instead + exif: true, + gps: true, + reviveValues: false, //don't convert timestamps + translateValues: false, //don't translate orientation from numbers to strings etc. + mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged + }; + + //function to convert timestamp into milliseconds taking offset into account + const timestampToMS = (timestamp: string, offset: string) => { + if (!timestamp) { + return undefined; + } + //replace : with - in the yyyy-mm-dd part of the timestamp. + let formattedTimestamp = timestamp.substring(0,9).replaceAll(':', '-') + timestamp.substring(9,timestamp.length); + if (formattedTimestamp.indexOf("Z") > 0) { //replace Z (and what comes after the Z) with offset + formattedTimestamp.substring(0, formattedTimestamp.indexOf("Z")) + (offset ? offset : '+00:00'); + } else if (formattedTimestamp.indexOf("+") > 0) { //don't do anything + } else { //add offset + formattedTimestamp = formattedTimestamp + (offset ? offset : '+00:00'); + } + //parse into MS and return + return Date.parse(formattedTimestamp); + } + + //function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp + const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { + let UTCTimestamp = gpsTimeStamp; + if (!UTCTimestamp && + gps && + gps.GPSDateStamp && + gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available + //GPS timestamp is always UTC (+00:00) + UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':'); + } + if (UTCTimestamp && timestamp) { + //offset in minutes is the difference between gps timestamp and given timestamp + //to calculate this correctly, we have to work with the same offset + const offsetMinutes = (timestampToMS(timestamp, '+00:00')- timestampToMS(UTCTimestamp, '+00:00')) / 1000 / 60; + if (-720 <= offsetMinutes && offsetMinutes <= 840) { + //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) + return (offsetMinutes < 0 ? "-" : "+") + //leading +/- + ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' + ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes + } else { + return undefined; + } + } else { + return undefined; + } + } + + //Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section) + const unescape = (tag: string) => { + return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { + return String.fromCharCode(parseInt(numStr, 10)); + }); + } + + try { + const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); + fileHandle = await fs.promises.open(fullPath, 'r'); + try { + await fileHandle.read(data, 0, Config.Media.photoMetadataSize, 0); + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } finally { + await fileHandle.close(); + } + try { + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + metadata.creationDate = stat.mtime.getTime(); + } catch (err) { + // ignoring errors + } + try { + //read the actual image size, don't rely on tags for this + const info = imageSize(fullPath); + metadata.size = { width: info.width, height: info.height }; + } catch (e) { + //in case of failure, set dimensions to 0 so they may be read via tags + metadata.size = { width: 0, height: 0 }; + } + + + try { //Parse iptc data using the IptcParser, which works correctly for both UTF-8 and ASCII + const iptcData = IptcParser.parse(data); + if (iptcData.country_or_primary_location_name) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = + iptcData.country_or_primary_location_name + .replace(/\0/g, '') + .trim(); + } + if (iptcData.province_or_state) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.state = iptcData.province_or_state + .replace(/\0/g, '') + .trim(); + } + if (iptcData.city) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.city = iptcData.city + .replace(/\0/g, '') + .trim(); + } + if (iptcData.object_name) { + metadata.title = iptcData.object_name.replace(/\0/g, '').trim(); + } + if (iptcData.caption) { + metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); + } + if (Array.isArray(iptcData.keywords)) { + metadata.keywords = iptcData.keywords; + } + + if (iptcData.date_time) { + metadata.creationDate = iptcData.date_time.getTime(); + } + } catch (err) { + // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); + } + + try { + let orientation = 1; //Orientation 1 is normal + const exif = await exifr.parse(data, exifrOptions); + //exif is structured in sections, we read the data by section + + //dc-section (subject is the only tag we want from dc) + if (exif.dc && + exif.dc.subject && + exif.dc.subject.length > 0) { + const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of subj) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + + //ifd0 section + if (exif.ifd0) { + if (exif.ifd0.ImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.ifd0.ImageWidth; + } + if (exif.ifd0.ImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.ifd0.ImageHeight; + } + if (exif.ifd0.Orientation) { + orientation = parseInt( + exif.ifd0.Orientation as any, + 10 + ) as number; + } + if (exif.ifd0.Make && exif.ifd0.Make !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.make = '' + exif.ifd0.Make; + } + if (exif.ifd0.Model && exif.ifd0.Model !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.model = '' + exif.ifd0.Model; + } + //if (exif.ifd0.ModifyDate) {} //Deferred to the exif-section where the other timestamps are + } + + //exif section starting with the date sectino + if (exif.exif) { + //Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date + //Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone. + if (exif.exif.DateTimeOriginal) { + //DateTimeOriginal is when the camera shutter closed + if (exif.exif.OffsetTimeOriginal) { //OffsetTimeOriginal is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, exif.exif.OffsetTimeOriginal); + metadata.creationDateOffset = exif.exif.OffsetTimeOriginal; + } else { + const alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence + //Create is when the camera wrote the file (typically within the same ms as shutter close) + if (exif.exif.OffsetTimeDigitized) { //OffsetTimeDigitized is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.CreateDate, exif.exif.OffsetTimeDigitized); + metadata.creationDateOffset = exif.exif.OffsetTimeDigitized; + } else { + const alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence + if (exif.exif.OffsetTime) { + //exif.Offsettime is the offset corresponding to ifd0.ModifyDate + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, exif.exif?.OffsetTime); + metadata.creationDateOffset = exif.exif?.OffsetTime + } else { + const alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ihdr["Creation Time"], any_offset); + metadata.creationDateOffset = any_offset; + } else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.xmp.MetadataDate, any_offset); + metadata.creationDateOffset = any_offset; + } + if (exif.exif.LensModel && exif.exif.LensModel !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.lens = '' + exif.exif.LensModel; + } + if (Utils.isUInt32(exif.exif.ISO)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10); + } + if (Utils.isFloat32(exif.exif.FocalLength)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.focalLength = parseFloat( + '' + exif.exif.FocalLength + ); + } + if (Utils.isFloat32(exif.exif.ExposureTime)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.exposure = parseFloat( + parseFloat('' + exif.exif.ExposureTime).toFixed(6) + ); + } + if (Utils.isFloat32(exif.exif.FNumber)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.fStop = parseFloat( + parseFloat('' + exif.exif.FNumber).toFixed(2) + ); + } + if (exif.exif.ExifImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.exif.ExifImageWidth; + } + if (exif.exif.ExifImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.exif.ExifImageHeight; + } + } + + //gps section + if (exif.gps) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.GPSData = metadata.positionData.GPSData || {}; + + if (Utils.isFloat32(exif.gps.longitude)) { + metadata.positionData.GPSData.longitude = parseFloat( + exif.gps.longitude.toFixed(6) + ); + } + if (Utils.isFloat32(exif.gps.latitude)) { + metadata.positionData.GPSData.latitude = parseFloat( + exif.gps.latitude.toFixed(6) + ); + } + + if (metadata.positionData) { + if (!metadata.positionData.GPSData || + Object.keys(metadata.positionData.GPSData).length === 0) { + metadata.positionData.GPSData = undefined; + metadata.positionData = undefined; + } + } + } + //photoshop section (sometimes has City, Country and State) + if (exif.photoshop) { + if (!metadata.positionData?.country && exif.photoshop.Country) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = unescape(exif.photoshop.Country); + } + if (!metadata.positionData?.state && exif.photoshop.State) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.state = unescape(exif.photoshop.State); + } + if (!metadata.positionData?.city && exif.photoshop.City) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.city = unescape(exif.photoshop.City); + } + } + + /////////////////////////////////////// + metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive + metadata.size.width = Math.max(metadata.size.width, 1); //ensure width dimension is positive + + //Before moving on to the XMP section (particularly the regions (mwg-rs)) + //we need to switch width and height for images that are rotated sideways + if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%) + // noinspection JSSuspiciousNameCombination + const height = metadata.size.width; + // noinspection JSSuspiciousNameCombination + metadata.size.width = metadata.size.height; + metadata.size.height = height; + } + /////////////////////////////////////// + + //xmp section + if (exif.xmp && exif.xmp.Rating) { + metadata.rating = exif.xmp.Rating; + if (metadata.rating < 0) { + metadata.rating = 0; + } + } + //xmp."mwg-rs" section + if (Config.Faces.enabled && + exif["mwg-rs"] && + exif["mwg-rs"].Regions) { + const faces: FaceRegion[] = []; + const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; + if (regionListVal) { + for (const regionRoot of regionListVal) { + let type; + let name; + let box; + const createFaceBox = ( + w: string, + h: string, + x: string, + y: string + ) => { + if (4 < orientation) { //roation is sidewards (90 or 270 degrees) + [x, y] = [y, x]; + [w, h] = [h, w]; + } + let swapX = 0; + let swapY = 0; + switch (orientation) { + case 2: //TOP RIGHT (Mirror horizontal): + case 6: //RIGHT TOP (Rotate 90 CW) + swapX = 1; + break; + case 3: // BOTTOM RIGHT (Rotate 180) + case 7: // RIGHT BOTTOM (Mirror horizontal and rotate 90 CW) + swapX = 1; + swapY = 1; + break; + case 4: //BOTTOM_LEFT (Mirror vertical) + case 8: //LEFT_BOTTOM (Rotate 270 CW) + swapY = 1; + break; + } + // converting ratio to px + return { + width: Math.round(parseFloat(w) * metadata.size.width), + height: Math.round(parseFloat(h) * metadata.size.height), + left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width), + top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height), + }; + }; + /* Adobe Lightroom based face region structure */ + if ( + regionRoot && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description']['mwg-rs:Area'] + ) { + const region = regionRoot['rdf:Description']; + const regionBox = region['mwg-rs:Area'].attributes; + + name = region['mwg-rs:Name']; + type = region['mwg-rs:Type']; + box = createFaceBox( + regionBox['stArea:w'], + regionBox['stArea:h'], + regionBox['stArea:x'], + regionBox['stArea:y'] + ); + /* Load exiftool edited face region structure, see github issue #191 */ + } else if ( + regionRoot && + regionRoot.Name && + regionRoot.Type && + regionRoot.Area + ) { + const regionBox = regionRoot.Area; + name = regionRoot.Name; + type = regionRoot.Type; + box = createFaceBox( + regionBox.w, + regionBox.h, + regionBox.x, + regionBox.y + ); + } + + if (type !== 'Face' || !name) { + continue; + } + + // convert center base box to corner based box + box.left = Math.round(Math.max(0, box.left - box.width / 2)); + box.top = Math.round(Math.max(0, box.top - box.height / 2)); + + + faces.push({ name, box }); + } + } + if (faces.length > 0) { + metadata.faces = faces; // save faces + if (Config.Faces.keywordsToPersons) { + // remove faces from keywords + metadata.faces.forEach((f) => { + const index = metadata.keywords.indexOf(f.name); + if (index !== -1) { + metadata.keywords.splice(index, 1); + } + }); + } + } + } + } catch (err) { + // ignoring errors + } + + if (!metadata.creationDate) { + // creationDate can be negative, when it was created before epoch (1970) + metadata.creationDate = 0; + } + + try { + // search for sidecar and merge metadata + const fullPathWithoutExt = path.parse(fullPath).name; + const sidecarPaths = [ + fullPath + '.xmp', + fullPath + '.XMP', + fullPathWithoutExt + '.xmp', + fullPathWithoutExt + '.XMP', + ]; + + for (const sidecarPath of sidecarPaths) { + if (fs.existsSync(sidecarPath)) { + const sidecarData = await exifr.sidecar(sidecarPath); + + if (sidecarData !== undefined) { + if ((sidecarData as SideCar).dc.subject !== undefined) { + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of (sidecarData as SideCar).dc.subject) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + if ((sidecarData as SideCar).xmp.Rating !== undefined) { + metadata.rating = (sidecarData as SideCar).xmp.Rating; + } + } + } + } + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); + Logger.silly(err); + } + + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } + return metadata; + } +} diff --git a/src/backend/model/messenger/EmailMessenger.ts b/src/backend/model/messenger/EmailMessenger.ts index c412c55f..c9afd8a9 100644 --- a/src/backend/model/messenger/EmailMessenger.ts +++ b/src/backend/model/messenger/EmailMessenger.ts @@ -5,6 +5,7 @@ import {MediaDTOWithThPath, Messenger} from './Messenger'; import {backendTexts} from '../../../common/BackendTexts'; import {DynamicConfig} from '../../../common/entities/DynamicConfig'; import {DefaultMessengers} from '../../../common/entities/job/JobDTO'; +import {Utils} from '../../../common/Utils'; export class EmailMessenger extends Messenger<{ emailTo: string, @@ -69,7 +70,7 @@ export class EmailMessenger extends Messenger<{ (media[i].metadata as PhotoMetadata).positionData?.country : ((media[i].metadata as PhotoMetadata).positionData?.city ? (media[i].metadata as PhotoMetadata).positionData?.city : ''); - const caption = (new Date(media[i].metadata.creationDate)).getFullYear() + (location ? ', ' + location : ''); + const caption = Utils.getFullYear(media[i].metadata.creationDate, media[i].metadata.creationDateOffset) + (location ? ', ' + location : ''); attachments.push({ filename: media[i].name, path: media[i].thumbnailPath, diff --git a/src/backend/routes/GalleryRouter.ts b/src/backend/routes/GalleryRouter.ts index 526da5a0..d21832a4 100644 --- a/src/backend/routes/GalleryRouter.ts +++ b/src/backend/routes/GalleryRouter.ts @@ -1,286 +1,286 @@ -import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; -import {Express} from 'express'; -import {GalleryMWs} from '../middlewares/GalleryMWs'; -import {RenderingMWs} from '../middlewares/RenderingMWs'; -import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs'; -import {UserRoles} from '../../common/entities/UserDTO'; -import {ThumbnailSourceType} from '../model/fileaccess/PhotoWorker'; -import {VersionMWs} from '../middlewares/VersionMWs'; -import {SupportedFormats} from '../../common/SupportedFormats'; -import {ServerTimingMWs} from '../middlewares/ServerTimingMWs'; -import {MetaFileMWs} from '../middlewares/MetaFileMWs'; -import {Config} from '../../common/config/private/Config'; - -export class GalleryRouter { - public static route(app: Express): void { - this.addGetImageIcon(app); - this.addGetVideoIcon(app); - this.addGetResizedPhoto(app); - this.addGetBestFitVideo(app); - this.addGetVideoThumbnail(app); - this.addGetImage(app); - this.addGetVideo(app); - this.addGetMetaFile(app); - this.addGetBestFitMetaFile(app); - this.addRandom(app); - this.addDirectoryList(app); - this.addDirectoryZip(app); - - this.addSearch(app); - this.addAutoComplete(app); - } - - protected static addDirectoryList(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/content/:directory(*)', Config.Server.apiPath + '/gallery/', Config.Server.apiPath + '/gallery//'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('directory'), - AuthenticationMWs.authorisePath('directory', true), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.listDirectory, - ThumbnailGeneratorMWs.addThumbnailInformation, - GalleryMWs.cleanUpGalleryResults, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } - - protected static addDirectoryZip(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/zip/:directory(*)'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('directory'), - AuthenticationMWs.authorisePath('directory', true), - - // specific part - ServerTimingMWs.addServerTiming, - GalleryMWs.zipDirectory - ); - } - - protected static addGetImage(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideo(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetBestFitVideo(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/bestFit', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - GalleryMWs.loadBestFitVideo, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetMetaFile(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.MetaFiles.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetBestFitMetaFile(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.MetaFiles.join('|') + - '))/bestFit', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - MetaFileMWs.compressGPX, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addRandom(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/random/:searchQueryDTO'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.getRandomImage, - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - /** - * Used for serving photo thumbnails and previews - * @param app - * @protected - */ - protected static addGetResizedPhoto(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))/:size', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Photo), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideoThumbnail(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/:size?', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Video), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideoIcon(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/icon', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetImageIcon(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))/icon', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Photo), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addSearch(app: Express): void { - app.get( - Config.Server.apiPath + '/search/:searchQueryDTO(*)', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.search, - ThumbnailGeneratorMWs.addThumbnailInformation, - GalleryMWs.cleanUpGalleryResults, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } - - protected static addAutoComplete(app: Express): void { - app.get( - Config.Server.apiPath + '/autocomplete/:text(*)', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.autocomplete, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } -} +import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; +import {Express} from 'express'; +import {GalleryMWs} from '../middlewares/GalleryMWs'; +import {RenderingMWs} from '../middlewares/RenderingMWs'; +import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs'; +import {UserRoles} from '../../common/entities/UserDTO'; +import {ThumbnailSourceType} from '../model/fileaccess/PhotoWorker'; +import {VersionMWs} from '../middlewares/VersionMWs'; +import {SupportedFormats} from '../../common/SupportedFormats'; +import {ServerTimingMWs} from '../middlewares/ServerTimingMWs'; +import {MetaFileMWs} from '../middlewares/MetaFileMWs'; +import {Config} from '../../common/config/private/Config'; + +export class GalleryRouter { + public static route(app: Express): void { + this.addGetImageIcon(app); + this.addGetVideoIcon(app); + this.addGetResizedPhoto(app); + this.addGetBestFitVideo(app); + this.addGetVideoThumbnail(app); + this.addGetImage(app); + this.addGetVideo(app); + this.addGetMetaFile(app); + this.addGetBestFitMetaFile(app); + this.addRandom(app); + this.addDirectoryList(app); + this.addDirectoryZip(app); + + this.addSearch(app); + this.addAutoComplete(app); + } + + protected static addDirectoryList(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/content/:directory(*)', Config.Server.apiPath + '/gallery/', Config.Server.apiPath + '/gallery//'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('directory'), + AuthenticationMWs.authorisePath('directory', true), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.listDirectory, + ThumbnailGeneratorMWs.addThumbnailInformation, + GalleryMWs.cleanUpGalleryResults, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } + + protected static addDirectoryZip(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/zip/:directory(*)'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('directory'), + AuthenticationMWs.authorisePath('directory', true), + + // specific part + ServerTimingMWs.addServerTiming, + GalleryMWs.zipDirectory + ); + } + + protected static addGetImage(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideo(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetBestFitVideo(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/bestFit', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + GalleryMWs.loadBestFitVideo, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetMetaFile(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.MetaFiles.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetBestFitMetaFile(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.MetaFiles.join('|') + + '))/bestFit', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + MetaFileMWs.compressGPX, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addRandom(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/random/:searchQueryDTO'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.getRandomImage, + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + /** + * Used for serving photo thumbnails and previews + * @param app + * @protected + */ + protected static addGetResizedPhoto(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))/:size', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Photo), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideoThumbnail(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/:size?', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Video), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideoIcon(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/icon', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetImageIcon(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))/icon', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Photo), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addSearch(app: Express): void { + app.get( + Config.Server.apiPath + '/search/:searchQueryDTO(*)', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.search, + ThumbnailGeneratorMWs.addThumbnailInformation, + GalleryMWs.cleanUpGalleryResults, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } + + protected static addAutoComplete(app: Express): void { + app.get( + Config.Server.apiPath + '/autocomplete/:text(*)', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.autocomplete, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } +} diff --git a/src/common/Utils.ts b/src/common/Utils.ts index ceef8811..67bc435a 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -110,6 +110,20 @@ export class Utils { return d; } + static getUTCFullYear(d: number | Date, offset: string) { + if (!(d instanceof Date)) { + d = new Date(d); + } + return new Date(new Date(d).toISOString().substring(0,19) + (offset ? offset : '')).getUTCFullYear(); + } + + static getFullYear(d: number | Date, offset: string) { + if (!(d instanceof Date)) { + d = new Date(d); + } + return new Date(new Date(d).toISOString().substring(0,19) + (offset ? offset : '')).getFullYear(); + } + static renderDataSize(size: number): string { const postFixes = ['B', 'KB', 'MB', 'GB', 'TB']; let index = 0; diff --git a/src/frontend/app/ui/duplicates/duplicates.component.html b/src/frontend/app/ui/duplicates/duplicates.component.html index e1044534..f1168a73 100644 --- a/src/frontend/app/ui/duplicates/duplicates.component.html +++ b/src/frontend/app/ui/duplicates/duplicates.component.html @@ -24,7 +24,8 @@ {{media.metadata.fileSize | fileSize}}

diff --git a/src/frontend/app/ui/gallery/filter/filter.service.ts b/src/frontend/app/ui/gallery/filter/filter.service.ts index 220483e1..e6bbf234 100644 --- a/src/frontend/app/ui/gallery/filter/filter.service.ts +++ b/src/frontend/app/ui/gallery/filter/filter.service.ts @@ -205,7 +205,7 @@ export class FilterService { const startMediaDate = new Date(floorDate(minDate)); prefiltered.media.forEach(m => { - const key = Math.floor((floorDate(m.metadata.creationDate) - startMediaDate.getTime()) / 1000 / usedDiv); + const key = Math.floor((floorDate(m.metadata.creationDate) - startMediaDate.getTime()) / 1000 / usedDiv); //TODO const getDate = (index: number) => { let d: Date; diff --git a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts index 1fe5309e..b96576e7 100644 --- a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts @@ -496,7 +496,7 @@ export class ControlsLightboxComponent implements OnDestroy, OnInit, OnChanges { case LightBoxTitleTexts.persons: return m.metadata.faces?.map(f => f.name)?.join(', '); case LightBoxTitleTexts.date: - return this.datePipe.transform(m.metadata.creationDate, 'longDate'); + return this.datePipe.transform(m.metadata.creationDate, 'longDate', m.metadata.creationDateOffset); case LightBoxTitleTexts.location: return ( m.metadata.positionData?.city || diff --git a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html index 3e1cbbfa..341aa65a 100644 --- a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html +++ b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html @@ -54,10 +54,10 @@
- {{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : 'UTC' }} + {{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
-
{{ media.metadata.creationDate | date : 'EEEE, HH:mm:ss' : 'UTC' }}
+
{{ media.metadata.creationDate | date : (media.metadata.creationDateOffset ? 'EEEE, HH:mm:ss ZZZZZ' : 'EEEE, HH:mm:ss') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
diff --git a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts index a1254d2a..542cc42f 100644 --- a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts @@ -148,7 +148,7 @@ export class InfoPanelLightboxComponent implements OnInit, OnChanges { isThisYear(): boolean { return ( new Date().getFullYear() === - new Date(this.media.metadata.creationDate).getUTCFullYear() + Utils.getUTCFullYear(this.media.metadata.creationDate, this.media.metadata.creationDateOffset) ); } diff --git a/src/frontend/app/ui/gallery/navigator/sorting.service.ts b/src/frontend/app/ui/gallery/navigator/sorting.service.ts index 2a3cca0d..7e74d737 100644 --- a/src/frontend/app/ui/gallery/navigator/sorting.service.ts +++ b/src/frontend/app/ui/gallery/navigator/sorting.service.ts @@ -184,7 +184,7 @@ export class GallerySortingService { private getGroupByNameFn(grouping: GroupingMethod) { switch (grouping.method) { case SortByTypes.Date: - return (m: MediaDTO) => this.datePipe.transform(m.metadata.creationDate, 'longDate', 'UTC'); + return (m: MediaDTO) => this.datePipe.transform(m.metadata.creationDate, 'longDate', m.metadata.creationDateOffset); case SortByTypes.Name: return (m: MediaDTO) => m.name.at(0).toUpperCase(); From e1e70fc13c47260e42c64cae7f66f0818696f210 Mon Sep 17 00:00:00 2001 From: grasdk Date: Fri, 16 Feb 2024 19:53:58 +0100 Subject: [PATCH 09/18] Reverted GalleryRouter, did not change it --- src/backend/routes/GalleryRouter.ts | 572 ++++++++++++++-------------- 1 file changed, 286 insertions(+), 286 deletions(-) diff --git a/src/backend/routes/GalleryRouter.ts b/src/backend/routes/GalleryRouter.ts index d21832a4..526da5a0 100644 --- a/src/backend/routes/GalleryRouter.ts +++ b/src/backend/routes/GalleryRouter.ts @@ -1,286 +1,286 @@ -import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; -import {Express} from 'express'; -import {GalleryMWs} from '../middlewares/GalleryMWs'; -import {RenderingMWs} from '../middlewares/RenderingMWs'; -import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs'; -import {UserRoles} from '../../common/entities/UserDTO'; -import {ThumbnailSourceType} from '../model/fileaccess/PhotoWorker'; -import {VersionMWs} from '../middlewares/VersionMWs'; -import {SupportedFormats} from '../../common/SupportedFormats'; -import {ServerTimingMWs} from '../middlewares/ServerTimingMWs'; -import {MetaFileMWs} from '../middlewares/MetaFileMWs'; -import {Config} from '../../common/config/private/Config'; - -export class GalleryRouter { - public static route(app: Express): void { - this.addGetImageIcon(app); - this.addGetVideoIcon(app); - this.addGetResizedPhoto(app); - this.addGetBestFitVideo(app); - this.addGetVideoThumbnail(app); - this.addGetImage(app); - this.addGetVideo(app); - this.addGetMetaFile(app); - this.addGetBestFitMetaFile(app); - this.addRandom(app); - this.addDirectoryList(app); - this.addDirectoryZip(app); - - this.addSearch(app); - this.addAutoComplete(app); - } - - protected static addDirectoryList(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/content/:directory(*)', Config.Server.apiPath + '/gallery/', Config.Server.apiPath + '/gallery//'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('directory'), - AuthenticationMWs.authorisePath('directory', true), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.listDirectory, - ThumbnailGeneratorMWs.addThumbnailInformation, - GalleryMWs.cleanUpGalleryResults, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } - - protected static addDirectoryZip(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/zip/:directory(*)'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('directory'), - AuthenticationMWs.authorisePath('directory', true), - - // specific part - ServerTimingMWs.addServerTiming, - GalleryMWs.zipDirectory - ); - } - - protected static addGetImage(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideo(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetBestFitVideo(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/bestFit', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - GalleryMWs.loadBestFitVideo, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetMetaFile(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.MetaFiles.join('|') + - '))', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetBestFitMetaFile(app: Express): void { - app.get( - [ - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.MetaFiles.join('|') + - '))/bestFit', - ], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - MetaFileMWs.compressGPX, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addRandom(app: Express): void { - app.get( - [Config.Server.apiPath + '/gallery/random/:searchQueryDTO'], - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.getRandomImage, - GalleryMWs.loadFile, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - /** - * Used for serving photo thumbnails and previews - * @param app - * @protected - */ - protected static addGetResizedPhoto(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))/:size', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Photo), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideoThumbnail(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/:size?', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Video), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetVideoIcon(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Videos.join('|') + - '))/icon', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addGetImageIcon(app: Express): void { - app.get( - Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + - SupportedFormats.Photos.join('|') + - '))/icon', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.normalizePathParam('mediaPath'), - AuthenticationMWs.authorisePath('mediaPath', false), - - // specific part - GalleryMWs.loadFile, - ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Photo), - ServerTimingMWs.addServerTiming, - RenderingMWs.renderFile - ); - } - - protected static addSearch(app: Express): void { - app.get( - Config.Server.apiPath + '/search/:searchQueryDTO(*)', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.search, - ThumbnailGeneratorMWs.addThumbnailInformation, - GalleryMWs.cleanUpGalleryResults, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } - - protected static addAutoComplete(app: Express): void { - app.get( - Config.Server.apiPath + '/autocomplete/:text(*)', - // common part - AuthenticationMWs.authenticate, - AuthenticationMWs.authorise(UserRoles.Guest), - VersionMWs.injectGalleryVersion, - - // specific part - GalleryMWs.autocomplete, - ServerTimingMWs.addServerTiming, - RenderingMWs.renderResult - ); - } -} +import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs'; +import {Express} from 'express'; +import {GalleryMWs} from '../middlewares/GalleryMWs'; +import {RenderingMWs} from '../middlewares/RenderingMWs'; +import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs'; +import {UserRoles} from '../../common/entities/UserDTO'; +import {ThumbnailSourceType} from '../model/fileaccess/PhotoWorker'; +import {VersionMWs} from '../middlewares/VersionMWs'; +import {SupportedFormats} from '../../common/SupportedFormats'; +import {ServerTimingMWs} from '../middlewares/ServerTimingMWs'; +import {MetaFileMWs} from '../middlewares/MetaFileMWs'; +import {Config} from '../../common/config/private/Config'; + +export class GalleryRouter { + public static route(app: Express): void { + this.addGetImageIcon(app); + this.addGetVideoIcon(app); + this.addGetResizedPhoto(app); + this.addGetBestFitVideo(app); + this.addGetVideoThumbnail(app); + this.addGetImage(app); + this.addGetVideo(app); + this.addGetMetaFile(app); + this.addGetBestFitMetaFile(app); + this.addRandom(app); + this.addDirectoryList(app); + this.addDirectoryZip(app); + + this.addSearch(app); + this.addAutoComplete(app); + } + + protected static addDirectoryList(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/content/:directory(*)', Config.Server.apiPath + '/gallery/', Config.Server.apiPath + '/gallery//'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('directory'), + AuthenticationMWs.authorisePath('directory', true), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.listDirectory, + ThumbnailGeneratorMWs.addThumbnailInformation, + GalleryMWs.cleanUpGalleryResults, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } + + protected static addDirectoryZip(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/zip/:directory(*)'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('directory'), + AuthenticationMWs.authorisePath('directory', true), + + // specific part + ServerTimingMWs.addServerTiming, + GalleryMWs.zipDirectory + ); + } + + protected static addGetImage(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideo(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetBestFitVideo(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/bestFit', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + GalleryMWs.loadBestFitVideo, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetMetaFile(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.MetaFiles.join('|') + + '))', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetBestFitMetaFile(app: Express): void { + app.get( + [ + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.MetaFiles.join('|') + + '))/bestFit', + ], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + MetaFileMWs.compressGPX, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addRandom(app: Express): void { + app.get( + [Config.Server.apiPath + '/gallery/random/:searchQueryDTO'], + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.getRandomImage, + GalleryMWs.loadFile, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + /** + * Used for serving photo thumbnails and previews + * @param app + * @protected + */ + protected static addGetResizedPhoto(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))/:size', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Photo), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideoThumbnail(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/:size?', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateThumbnailFactory(ThumbnailSourceType.Video), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetVideoIcon(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Videos.join('|') + + '))/icon', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addGetImageIcon(app: Express): void { + app.get( + Config.Server.apiPath + '/gallery/content/:mediaPath(*.(' + + SupportedFormats.Photos.join('|') + + '))/icon', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.normalizePathParam('mediaPath'), + AuthenticationMWs.authorisePath('mediaPath', false), + + // specific part + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Photo), + ServerTimingMWs.addServerTiming, + RenderingMWs.renderFile + ); + } + + protected static addSearch(app: Express): void { + app.get( + Config.Server.apiPath + '/search/:searchQueryDTO(*)', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.search, + ThumbnailGeneratorMWs.addThumbnailInformation, + GalleryMWs.cleanUpGalleryResults, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } + + protected static addAutoComplete(app: Express): void { + app.get( + Config.Server.apiPath + '/autocomplete/:text(*)', + // common part + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Guest), + VersionMWs.injectGalleryVersion, + + // specific part + GalleryMWs.autocomplete, + ServerTimingMWs.addServerTiming, + RenderingMWs.renderResult + ); + } +} From d33e29cd82cac17b3118c188a3f0b038f488d1dc Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Mon, 19 Feb 2024 22:15:57 +0100 Subject: [PATCH 10/18] Add basic extension UI #784 --- package-lock.json | 11 ++- package.json | 2 +- src/backend/middlewares/RenderingMWs.ts | 1 + .../model/extension/ExtensionConfigWrapper.ts | 37 +++---- .../model/extension/ExtensionManager.ts | 22 +++-- .../subconfigs/ServerExtensionsConfig.ts | 12 +-- src/common/config/public/ClientConfig.ts | 3 +- src/frontend/app/ui/admin/admin.component.ts | 16 ++-- .../template/CustomSettingsEntries.ts | 21 +++- .../settings-entry.component.html | 20 ++-- .../settings-entry.component.ts | 2 +- .../settings/template/template.component.html | 96 ++++++++++++------- .../settings/template/template.component.ts | 59 +++++++----- 13 files changed, 175 insertions(+), 127 deletions(-) diff --git a/package-lock.json b/package-lock.json index b758647b..7f1fc96f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "sharp": "0.31.3", "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", - "typeconfig": "2.1.2", + "typeconfig": "2.2.7", "typeorm": "0.3.12", "xml2js": "0.6.2" }, @@ -20385,8 +20385,9 @@ } }, "node_modules/typeconfig": { - "version": "2.1.2", - "license": "MIT", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", + "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", "dependencies": { "minimist": "1.2.8" } @@ -35319,7 +35320,9 @@ } }, "typeconfig": { - "version": "2.1.2", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", + "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", "requires": { "minimist": "1.2.8" } diff --git a/package.json b/package.json index 5f300ea9..8971a16f 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "sharp": "0.31.3", "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", - "typeconfig": "2.1.2", + "typeconfig": "2.2.7", "typeorm": "0.3.12", "xml2js": "0.6.2" }, diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index 404c1814..2576db38 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -119,6 +119,7 @@ export class RenderingMWs { skipTags: {secret: true} as TAGS }) as PrivateConfigClass ); + console.log(message.result.Extensions.extensions); res.json(message); } diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index cad0ad24..2b61ab2c 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,4 +1,4 @@ -import {ConfigProperty, IConfigClass} from 'typeconfig/common'; +import {IConfigClass} from 'typeconfig/common'; import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; import {ConfigClassBuilder} from 'typeconfig/node'; import {IExtensionConfig} from './IExtension'; @@ -32,22 +32,14 @@ export class ExtensionConfig implements IExtensionConfig { constructor(private readonly extensionFolder: string) { } - private findConfig(config: PrivateConfigClass) { - let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); - if (!c) { - c = new ServerExtensionsEntryConfig(this.extensionFolder); - config.Extensions.extensions.push(c); - } + private findConfig(config: PrivateConfigClass): ServerExtensionsEntryConfig { + let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); + if (!c) { + c = new ServerExtensionsEntryConfig(this.extensionFolder); + config.Extensions.extensions.push(c); + } + return c; - if (!config.Extensions.extensions2[this.extensionFolder]) { - Object.defineProperty(config.Extensions.extensions2, this.extensionFolder, - ConfigProperty({type: ServerExtensionsEntryConfig})(config.Extensions.extensions2, this.extensionFolder)); - // config.Extensions.extensions2[this.extensionFolder] = c as any; - - config.Extensions.extensions2[this.extensionFolder] = c; - - } - return config.Extensions.extensions2[this.extensionFolder]; } public getConfig(): C { @@ -67,11 +59,12 @@ export class ExtensionConfig implements IExtensionConfig { const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); const extConf = this.findConfig(config); // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); - //extConf.configs = confTemplate; - Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, - ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); - console.log(config.Extensions.extensions2[this.extensionFolder].configs); - config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; - console.log(config.Extensions.extensions2[this.extensionFolder].configs); + extConf.configs = confTemplate; + console.log(((config as any).toJSON({attachState: true})).Extensions.extensions); + /* Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, + ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); + console.log(config.Extensions.extensions2[this.extensionFolder].configs); + config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; + console.log(config.Extensions.extensions2[this.extensionFolder].configs);*/ } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 11f50aa4..795a6b09 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -73,10 +73,10 @@ export class ExtensionManager implements IObjectManager { const extList = fs - .readdirSync(ProjectPath.ExtensionFolder) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); extList.sort(); // delete not existing extensions @@ -85,7 +85,7 @@ export class ExtensionManager implements IObjectManager { // Add new extensions const ePaths = Config.Extensions.extensions.map(ec => ec.path); extList.filter(ep => ePaths.indexOf(ep) === -1).forEach(ep => - Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); + Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.extensions.map(ec => ec.path))); } @@ -118,10 +118,14 @@ export class ExtensionManager implements IObjectManager { } 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 - }); + if (fs.existsSync(path.join(extPath, 'node_modules'))) { + Logger.debug(LOG_TAG, `node_modules folder exists. Skipping "npm install".`); + } else { + 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) { diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts index 22f7ad66..4d62097e 100644 --- a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; -import {IConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass'; +import {GenericConfigType} from 'typeconfig/src/GenericConfigType'; @SubConfigClass({softReadonly: true}) export class ServerExtensionsEntryConfig { @@ -28,12 +28,13 @@ export class ServerExtensionsEntryConfig { path: string = ''; @ConfigProperty({ + type: GenericConfigType, tags: { name: $localize`Config`, priority: ConfigPriority.advanced } }) - configs: IConfigClassPrivate; + configs: GenericConfigType; } @SubConfigClass({softReadonly: true}) @@ -59,13 +60,6 @@ export class ServerExtensionsConfig extends ClientExtensionsConfig { }) extensions: ServerExtensionsEntryConfig[] = []; - @ConfigProperty({ - tags: { - name: $localize`Installed extensions2`, - priority: ConfigPriority.advanced - } - }) - extensions2: Record = {}; @ConfigProperty({ tags: { diff --git a/src/common/config/public/ClientConfig.ts b/src/common/config/public/ClientConfig.ts index 3ac5b769..5d24388d 100644 --- a/src/common/config/public/ClientConfig.ts +++ b/src/common/config/public/ClientConfig.ts @@ -11,7 +11,7 @@ declare let $localize: (s: TemplateStringsArray) => string; if (typeof $localize === 'undefined') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - global.$localize = (s) => s; + global.$localize = (s) => s[0]; } @@ -1034,7 +1034,6 @@ export class ThemesConfig { name: $localize`Selected theme css`, //this is a 'hack' to the UI settings. UI will only show the selected setting's css uiDisabled: (sb: ThemesConfig) => !sb.enabled, relevant: (c: ThemesConfig) => c.selectedTheme !== 'default', - uiType: 'SelectedThemeSettings' } as TAGS, description: $localize`Adds these css settings as it is to the end of the body tag of the page.` }) diff --git a/src/frontend/app/ui/admin/admin.component.ts b/src/frontend/app/ui/admin/admin.component.ts index 04feb789..5fb5d307 100644 --- a/src/frontend/app/ui/admin/admin.component.ts +++ b/src/frontend/app/ui/admin/admin.component.ts @@ -30,12 +30,12 @@ export class AdminComponent implements OnInit, AfterViewInit { public readonly configPaths: string[] = []; constructor( - private authService: AuthenticationService, - private navigation: NavigationService, - public viewportScroller: ViewportScroller, - public notificationService: NotificationService, - public settingsService: SettingsService, - private piTitleService: PiTitleService + private authService: AuthenticationService, + private navigation: NavigationService, + public viewportScroller: ViewportScroller, + public notificationService: NotificationService, + public settingsService: SettingsService, + private piTitleService: PiTitleService ) { this.configPriorities = enumToTranslatedArray(ConfigPriority); this.configStyles = enumToTranslatedArray(ConfigStyle); @@ -50,8 +50,8 @@ export class AdminComponent implements OnInit, AfterViewInit { ngOnInit(): void { if ( - !this.authService.isAuthenticated() || - this.authService.user.value.role < UserRoles.Admin + !this.authService.isAuthenticated() || + this.authService.user.value.role < UserRoles.Admin ) { this.navigation.toLogin(); return; diff --git a/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts b/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts index b2346b6c..d7470668 100644 --- a/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts +++ b/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts @@ -1,5 +1,15 @@ import {propertyTypes} from 'typeconfig/common'; -import {ClientGroupingConfig, ClientSortingConfig, SVGIconConfig} from '../../../../../common/config/public/ClientConfig'; +import { + ClientGroupingConfig, + ClientSortingConfig, + MapLayers, + MapPathGroupConfig, + MapPathGroupThemeConfig, + NavigationLinkConfig, + SVGIconConfig, + ThemeConfig +} from '../../../../../common/config/public/ClientConfig'; +import {JobScheduleConfig, UserConfig} from '../../../../../common/config/private/PrivateConfig'; /** * Configuration in these class have a custom UI @@ -8,6 +18,13 @@ export class CustomSettingsEntries { public static readonly entries = [ {c: ClientSortingConfig, name: 'ClientSortingConfig'}, {c: ClientGroupingConfig, name: 'ClientGroupingConfig'}, + {c: MapLayers, name: 'MapLayers'}, + {c: JobScheduleConfig, name: 'JobScheduleConfig'}, + {c: UserConfig, name: 'UserConfig'}, + {c: NavigationLinkConfig, name: 'NavigationLinkConfig'}, + {c: MapPathGroupThemeConfig, name: 'MapPathGroupThemeConfig'}, + {c: MapPathGroupConfig, name: 'MapPathGroupConfig'}, + {c: ThemeConfig, name: 'ThemeConfig'}, {c: SVGIconConfig, name: 'SVGIconConfig'}, ]; @@ -46,7 +63,7 @@ export class CustomSettingsEntries { return cN; } - public static iS(s: { tags?: { uiType?: string }, type?: propertyTypes }) { + public static iS(s: { tags?: { uiType?: string }, type?: propertyTypes, arrayType?: propertyTypes }) { const c = this.getConfigName(s); return this.entries.findIndex(e => e.name == c) !== -1; } diff --git a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html index f6bdbfbe..a04a9801 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html +++ b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html @@ -12,7 +12,6 @@ [hidden]="shouldHide">
-
- +
@@ -295,7 +296,7 @@ - +
@@ -357,7 +358,7 @@
- +
@@ -403,7 +404,7 @@
- +
@@ -468,7 +469,8 @@
- + +
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 1d235785..4857ba25 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 @@ -258,7 +258,7 @@ export class SettingsEntryComponent this.arrayType !== 'MapPathGroupConfig' && this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && - this.arrayType !== 'JobScheduleConfig' && + this.arrayType !== 'JobScheduleConfig-Array' && this.arrayType !== 'UserConfig') { this.uiType = 'StringInput'; } diff --git a/src/frontend/app/ui/settings/template/template.component.html b/src/frontend/app/ui/settings/template/template.component.html index 35f7fd8d..35af7b9b 100644 --- a/src/frontend/app/ui/settings/template/template.component.html +++ b/src/frontend/app/ui/settings/template/template.component.html @@ -3,7 +3,7 @@
- {{Name}} + {{ Name }}
@@ -22,7 +22,7 @@
- + - {{Name}} config is not supported with these settings. + {{ Name }} config is not supported with these settings.
@@ -72,46 +72,71 @@ let-confPath="confPath"> - - - -
-
-
- - {{rStates?.value.__state[ck].tags?.name || ck}} -
- + + +
+
+
{{ rStates?.value.__state[ck].tags?.name || ck }}
+
+
+
+
+ +
- -
-
-
{{rStates?.value.__state[ck].tags?.name || ck}}
-
-
-
+ + + + + + + + + + +
+
+
+ + {{ rStates?.value.__state[ck].tags?.name || ck }} +
+
-
- -
+ + +
+
+
{{ rStates?.value.__state[ck].tags?.name || ck }}
+
+
+
+
+
+
+ +
+
@@ -125,12 +150,14 @@ >
+
+
diff --git a/src/frontend/app/ui/settings/template/template.component.ts b/src/frontend/app/ui/settings/template/template.component.ts index 6c115e31..70aa8f9c 100644 --- a/src/frontend/app/ui/settings/template/template.component.ts +++ b/src/frontend/app/ui/settings/template/template.component.ts @@ -79,11 +79,11 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting public readonly ConfigStyle = ConfigStyle; constructor( - protected authService: AuthenticationService, - private navigation: NavigationService, - protected notification: NotificationService, - public settingsService: SettingsService, - public jobsService: ScheduledJobsService, + protected authService: AuthenticationService, + private navigation: NavigationService, + protected notification: NotificationService, + public settingsService: SettingsService, + public jobsService: ScheduledJobsService, ) { } @@ -97,7 +97,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting this.nestedConfigs = []; for (const key of this.getKeys(this.states)) { if (this.states.value.__state[key].isConfigType && - this.states?.value.__state[key].tags?.uiIcon) { + this.states?.value.__state[key].tags?.uiIcon) { this.nestedConfigs.push({ id: this.ConfigPath + '.' + key, name: this.states?.value.__state[key].tags?.name, @@ -112,8 +112,8 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting ngOnInit(): void { if ( - !this.authService.isAuthenticated() || - this.authService.user.value.role < UserRoles.Admin + !this.authService.isAuthenticated() || + this.authService.user.value.role < UserRoles.Admin ) { this.navigation.toLogin(); return; @@ -143,7 +143,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting if (sliceFN) { this.sliceFN = sliceFN; this.settingsSubscription = this.settingsService.settings.subscribe( - this.onNewSettings + this.onNewSettings ); } } @@ -171,31 +171,31 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } if (state.tags && - ((state.tags.relevant && !state.tags.relevant(parent.value)) - || state.tags.secret)) { + ((state.tags.relevant && !state.tags.relevant(parent.value)) + || state.tags.secret)) { return true; } // if all sub elements are hidden, hide the parent too. if (state.isConfigType) { if (state.value && state.value.__state && - Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) { + Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) { return true; } } const forcedVisibility = !(state.tags?.priority > this.settingsService.configPriority || - //if this value should not change in Docker, lets hide it - (this.settingsService.configPriority === ConfigPriority.basic && - state.tags?.dockerSensitive && this.settingsService.settings.value.Environment.isDocker)); + //if this value should not change in Docker, lets hide it + (this.settingsService.configPriority === ConfigPriority.basic && + state.tags?.dockerSensitive && this.settingsService.settings.value.Environment.isDocker)); if (state.isConfigArrayType) { for (let i = 0; i < state.value?.length; ++i) { for (const k of Object.keys(state.value[i].__state)) { if (!Utils.equalsFilter( - state.value[i]?.__state[k]?.value, - state.default[i] ? state.default[i][k] : undefined, - ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { + state.value[i]?.__state[k]?.value, + state.default[i] ? state.default[i][k] : undefined, + ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { return false; } @@ -206,10 +206,10 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return (!forcedVisibility && - Utils.equalsFilter(state.value, state.default, - ['__propPath', '__created', '__prototype', '__rootConfig']) && - Utils.equalsFilter(state.original, state.default, - ['__propPath', '__created', '__prototype', '__rootConfig'])); + Utils.equalsFilter(state.value, state.default, + ['__propPath', '__created', '__prototype', '__rootConfig']) && + Utils.equalsFilter(state.original, state.default, + ['__propPath', '__created', '__prototype', '__rootConfig'])); }; }; @@ -246,7 +246,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } if (typeof state.original === 'object') { return Utils.equalsFilter(state.value, state.original, - ['__propPath', '__created', '__prototype', '__rootConfig', '__state']); + ['__propPath', '__created', '__prototype', '__rootConfig', '__state']); } if (typeof state.original !== 'undefined') { return state.value === state.original; @@ -271,10 +271,18 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting this.getSettings(); } + /** + * main template should list it + * @param c + */ isExpandableConfig(c: ConfigState) { return c.isConfigType && !CustomSettingsEntries.iS(c); } + isExpandableArrayConfig(c: ConfigState) { + return c.isConfigArrayType && !CustomSettingsEntries.iS(c); + } + public async save(): Promise { this.inProgress = true; @@ -284,8 +292,8 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting await this.settingsService.updateSettings(state, this.ConfigPath); await this.getSettings(); this.notification.success( - this.Name + ' ' + $localize`settings saved`, - $localize`Success` + this.Name + ' ' + $localize`settings saved`, + $localize`Success` ); this.inProgress = false; return true; @@ -328,7 +336,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return 1; } return (s[a].tags?.name as string || a).localeCompare(s[b].tags?.name || b); - }); states.keys = keys; return states.keys; From 6009ac649f994e761125f15565836e6b20a701a8 Mon Sep 17 00:00:00 2001 From: grasdk <115414609+grasdk@users.noreply.github.com> Date: Wed, 21 Feb 2024 23:06:24 +0100 Subject: [PATCH 11/18] effective storage of offset (#6) --- .gitignore | 3 ++- .../model/database/enitites/MediaEntity.ts | 8 ++++++- .../model/fileaccess/MetadataLoader.ts | 9 +------- src/common/Utils.ts | 23 +++++++++++++++++++ src/common/entities/ConentWrapper.ts | 13 +++++++++++ test/TestHelper.ts | 7 ++++++ 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 2f9ed1b7..6efad7e3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ test.* *.sublime-workspace .DS_Store /coverage/ -.nyc_output/ \ No newline at end of file +.nyc_output/ +.vscode* \ No newline at end of file diff --git a/src/backend/model/database/enitites/MediaEntity.ts b/src/backend/model/database/enitites/MediaEntity.ts index 57c13132..5eb17224 100644 --- a/src/backend/model/database/enitites/MediaEntity.ts +++ b/src/backend/model/database/enitites/MediaEntity.ts @@ -4,6 +4,7 @@ import {MediaDimension, MediaDTO, MediaMetadata,} from '../../../../common/entit import {PersonJunctionTable} from './PersonJunctionTable'; import {columnCharsetCS} from './EntityUtils'; import {CameraMetadata, FaceRegion, GPSMetadata, PositionMetaData,} from '../../../../common/entities/PhotoDTO'; +import { Utils } from '../../../../common/Utils'; export class MediaDimensionEntity implements MediaDimension { @Column('int') @@ -106,7 +107,12 @@ export class MediaMetadataEntity implements MediaMetadata { @Index() creationDate: number; - @Column('text') + @Column('smallint', { + transformer: { + from: (v) => Utils.getOffsetString(v), //from database repr. as smallint (minutes) to string (+/-HH:MM) + to: (v) => Utils.getOffsetMinutes(v), //from entiry repr. as string (+/-HH:MM) to smallint (minutes) + }, + }) creationDateOffset?: string; diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index cc4c05b8..908e0116 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -226,14 +226,7 @@ export class MetadataLoader { //offset in minutes is the difference between gps timestamp and given timestamp //to calculate this correctly, we have to work with the same offset const offsetMinutes = (timestampToMS(timestamp, '+00:00')- timestampToMS(UTCTimestamp, '+00:00')) / 1000 / 60; - if (-720 <= offsetMinutes && offsetMinutes <= 840) { - //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) - return (offsetMinutes < 0 ? "-" : "+") + //leading +/- - ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' - ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes - } else { - return undefined; - } + return Utils.getOffsetString(offsetMinutes); } else { return undefined; } diff --git a/src/common/Utils.ts b/src/common/Utils.ts index 67bc435a..2e6ae0f4 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -124,6 +124,29 @@ export class Utils { return new Date(new Date(d).toISOString().substring(0,19) + (offset ? offset : '')).getFullYear(); } + static getOffsetString(offsetMinutes: number) { + if (-720 <= offsetMinutes && offsetMinutes <= 840) { + //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) + return (offsetMinutes < 0 ? "-" : "+") + //leading +/- + ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' + ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes + } else { + return undefined; + } + } + + static getOffsetMinutes(offsetString: string) { //Convert offset string (+HH:MM or -HH:MM) into a minute value + const regex = /^([+\-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. + //-12:00 is the lowest valid UTC-offset, but we allow down to -14 for efficiency + if (regex.test(offsetString)) { + let hhmm = offsetString.split(":"); + let hours = parseInt(hhmm[0]); + return hours < 0 ? ((hours*60) - parseInt(hhmm[1])) : ((hours*60) + parseInt(hhmm[1])); + } else { + return undefined; + } + } + static renderDataSize(size: number): string { const postFixes = ['B', 'KB', 'MB', 'GB', 'TB']; let index = 0; diff --git a/src/common/entities/ConentWrapper.ts b/src/common/entities/ConentWrapper.ts index f0321575..aedc1794 100644 --- a/src/common/entities/ConentWrapper.ts +++ b/src/common/entities/ConentWrapper.ts @@ -79,6 +79,11 @@ export class ContentWrapper { (media as MediaDTO).metadata['t'] = (media as MediaDTO).metadata.creationDate / 1000; // skip millies delete (media as MediaDTO).metadata.creationDate; + if ((media as MediaDTO).metadata.creationDateOffset) { + // @ts-ignore + (media as MediaDTO).metadata['o'] = Utils.getOffsetMinutes((media as MediaDTO).metadata.creationDateOffset); // offset in minutes + delete (media as MediaDTO).metadata.creationDateOffset; + } if ((media as PhotoDTO).metadata.rating) { // @ts-ignore @@ -338,6 +343,14 @@ export class ContentWrapper { delete (media as PhotoDTO).metadata['t']; } + // @ts-ignore + if (typeof (media as PhotoDTO).metadata['o'] !== 'undefined') { + // @ts-ignore + (media as PhotoDTO).metadata.creationDateOffset = Utils.getOffsetString((media as PhotoDTO).metadata['o']) ;//convert offset from minutes to String + // @ts-ignore + delete (media as PhotoDTO).metadata['o']; + } + // @ts-ignore if (typeof (media as PhotoDTO).metadata['r'] !== 'undefined') { // @ts-ignore diff --git a/test/TestHelper.ts b/test/TestHelper.ts index 0c11a5cc..8c24e85c 100644 --- a/test/TestHelper.ts +++ b/test/TestHelper.ts @@ -55,6 +55,7 @@ export class TestHelper { m.caption = null; m.size = sd; m.creationDate = 1656069387772; + m.creationDateOffset = "+02:00" m.fileSize = 123456789; // m.rating = 0; no rating by default @@ -101,6 +102,7 @@ export class TestHelper { m.positionData = pd; m.size = sd; m.creationDate = 1656069387772; + m.creationDateOffset = "-05:00"; m.fileSize = 123456789; // m.rating = 0; no rating by default @@ -177,6 +179,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 10; p.metadata.positionData.GPSData.longitude = 10; p.metadata.creationDate = 1656069387772 - 1000; + p.metadata.creationDateOffset = "+00:00"; p.metadata.rating = 1; p.metadata.size.height = 1000; p.metadata.size.width = 1000; @@ -215,6 +218,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = -10; p.metadata.positionData.GPSData.longitude = -10; p.metadata.creationDate = 1656069387772 - 2000; + p.metadata.creationDateOffset = "+11:00"; p.metadata.rating = 2; p.metadata.size.height = 2000; p.metadata.size.width = 1000; @@ -247,6 +251,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 10; p.metadata.positionData.GPSData.longitude = 15; p.metadata.creationDate = 1656069387772 - 3000; + p.metadata.creationDateOffset = "-03:45"; p.metadata.rating = 3; p.metadata.size.height = 1000; p.metadata.size.width = 2000; @@ -275,6 +280,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 15; p.metadata.positionData.GPSData.longitude = 10; p.metadata.creationDate = 1656069387772 - 4000; + p.metadata.creationDateOffset = "+04:30"; p.metadata.size.height = 3000; p.metadata.size.width = 2000; @@ -394,6 +400,7 @@ export class TestHelper { positionData: pd, size: sd, creationDate: Date.now() + ++TestHelper.creationCounter, + creationDateOffset: "+01:00", fileSize: rndInt(10000), caption: rndStr(), rating: rndInt(5) as any, From e0d9bdf2b2ead483c4a8ebc0602804a95cd7481f Mon Sep 17 00:00:00 2001 From: grasdk <115414609+grasdk@users.noreply.github.com> Date: Wed, 21 Feb 2024 23:38:07 +0100 Subject: [PATCH 12/18] Feature/timestamp use2 (#7) * effective storage of offset * added comments to searchmanager.ts fixed linting error in utils --- src/backend/model/database/SearchManager.ts | 25 ++++++++++++++------- src/common/Utils.ts | 6 ++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/backend/model/database/SearchManager.ts b/src/backend/model/database/SearchManager.ts index 609dc776..3037ddfe 100644 --- a/src/backend/model/database/SearchManager.ts +++ b/src/backend/model/database/SearchManager.ts @@ -364,7 +364,7 @@ export class SearchManager { for (const sort of sortings) { switch (sort.method) { case SortByTypes.Date: - query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC'); + query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC'); //If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). If taken into account, it will alter the sort order. Probably should not be done. break; case SortByTypes.Rating: query.addOrderBy('media.metadata.rating', sort.ascending ? 'ASC' : 'DESC'); @@ -563,7 +563,12 @@ export class SearchManager { const textParam: { [key: string]: unknown } = {}; textParam['from' + queryId] = (query as FromDateSearch).value; q.where( - `media.metadata.creationDate ${relation} :from${queryId}`, + `media.metadata.creationDate ${relation} :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). + //Example: -600 means in the database UTC-10:00. The time 20:00 in the evening in the UTC-10 timezone, is actually 06:00 the next morning + //in UTC+00:00. To make search take that into account, one can subtract the offset from the creationDate to "pretend" the photo is taken + //in UTC time. Subtracting -600 minutes (because it's the -10:00 timezone), corresponds to adding 10 hours to the photo's timestamp, thus + //bringing it into the next day as if it was taken at UTC+00:00. Similarly subtracting a positive timezone from a timestamp will "pretend" + //the photo is taken earlier in time (e.g. subtracting 300 from the UTC+05:00 timezone). textParam ); @@ -585,8 +590,8 @@ export class SearchManager { const textParam: { [key: string]: unknown } = {}; textParam['to' + queryId] = (query as ToDateSearch).value; q.where( - `media.metadata.creationDate ${relation} :to${queryId}`, - textParam + `media.metadata.creationDate ${relation} :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. + textParam ); return q; @@ -790,15 +795,15 @@ export class SearchManager { if (tq.negate) { q.where( - `media.metadata.creationDate >= :to${queryId}`, + `media.metadata.creationDate >= :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam - ).orWhere(`media.metadata.creationDate < :from${queryId}`, + ).orWhere(`media.metadata.creationDate < :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam); } else { q.where( - `media.metadata.creationDate < :to${queryId}`, + `media.metadata.creationDate < :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam - ).andWhere(`media.metadata.creationDate >= :from${queryId}`, + ).andWhere(`media.metadata.creationDate >= :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam); } @@ -821,10 +826,12 @@ export class SearchManager { if (Config.Database.type === DatabaseType.sqlite) { if (tq.daysLength == 0) { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)` ); } else { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)` )[whereFN](`CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`, textParam); @@ -832,10 +839,12 @@ export class SearchManager { } else { if (tq.daysLength == 0) { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)` ); } else { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)` )[whereFN](`CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationBottom} CAST(DATE_FORMAT((DATE_ADD(curdate(), INTERVAL -:diff${queryId} DAY)),'${duration}') AS SIGNED)`, textParam); diff --git a/src/common/Utils.ts b/src/common/Utils.ts index 2e6ae0f4..e0a475d6 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -136,11 +136,11 @@ export class Utils { } static getOffsetMinutes(offsetString: string) { //Convert offset string (+HH:MM or -HH:MM) into a minute value - const regex = /^([+\-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. + const regex = /^([+-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. //-12:00 is the lowest valid UTC-offset, but we allow down to -14 for efficiency if (regex.test(offsetString)) { - let hhmm = offsetString.split(":"); - let hours = parseInt(hhmm[0]); + const hhmm = offsetString.split(":"); + const hours = parseInt(hhmm[0]); return hours < 0 ? ((hours*60) - parseInt(hhmm[1])) : ((hours*60) + parseInt(hhmm[1])); } else { return undefined; From eed4f0b6fab5ce772188a5b5c562ff284b1fb472 Mon Sep 17 00:00:00 2001 From: veroxzik <43590004+veroxzik@users.noreply.github.com> Date: Thu, 22 Feb 2024 19:18:31 -0500 Subject: [PATCH 13/18] Fix zoom behavior and add horizontal scrolling. --- .../controls.lightbox.gallery.component.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts index 1fe5309e..7117e890 100644 --- a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts @@ -146,13 +146,25 @@ export class ControlsLightboxComponent implements OnDestroy, OnInit, OnChanges { } } - wheel($event: { deltaY: number }): void { - if (!this.activePhoto || this.activePhoto.gridMedia.isVideo()) { + wheel($event: { deltaX: number, deltaY: number }): void { + if (!this.activePhoto) { + return; + } + if ($event.deltaX < 0) { + if (this.navigation.hasPrev) { + this.previousPhoto.emit(); + } + } else if ($event.deltaX > 0) { + if (this.navigation.hasNext) { + this.nextMediaManuallyTriggered(); + } + } + if (this.activePhoto.gridMedia.isVideo()) { return; } if ($event.deltaY < 0) { this.zoomIn(); - } else { + } else if ($event.deltaY > 0) { this.zoomOut(); } } @@ -537,4 +549,3 @@ export class ControlsLightboxComponent implements OnDestroy, OnInit, OnChanges { } } - From cb90d08c881fe5d4ae76e2a45cbe89ea48c1323c Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 2 Mar 2024 22:18:31 +0100 Subject: [PATCH 14/18] Add basic extension UI #784 --- package-lock.json | 14 +++++++------- package.json | 2 +- src/backend/middlewares/RenderingMWs.ts | 1 - src/backend/middlewares/SharingMWs.ts | 1 - src/backend/middlewares/admin/SettingsMWs.ts | 6 +++--- .../model/extension/ExtensionConfigWrapper.ts | 10 ++-------- src/backend/model/extension/ExtensionManager.ts | 4 ++++ src/backend/model/jobs/jobs/TopPickSendJob.ts | 1 - .../private/subconfigs/ServerExtensionsConfig.ts | 1 + .../search-field-base.gallery.component.ts | 1 - .../settings-entry/settings-entry.component.ts | 1 - 11 files changed, 18 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f1fc96f..a0390b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "sharp": "0.31.3", "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", - "typeconfig": "2.2.7", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, @@ -20385,9 +20385,9 @@ } }, "node_modules/typeconfig": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", - "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.11.tgz", + "integrity": "sha512-Knj+1kbIJ4zOZlUm2TPSWZUoiOW4txrmPyf6oyuBhaDQDlGxpSL5jobF3vVV9mZElK1V3ZQVeTgvGaiDyeT8mQ==", "dependencies": { "minimist": "1.2.8" } @@ -35320,9 +35320,9 @@ } }, "typeconfig": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", - "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.11.tgz", + "integrity": "sha512-Knj+1kbIJ4zOZlUm2TPSWZUoiOW4txrmPyf6oyuBhaDQDlGxpSL5jobF3vVV9mZElK1V3ZQVeTgvGaiDyeT8mQ==", "requires": { "minimist": "1.2.8" } diff --git a/package.json b/package.json index 8971a16f..f7f93af7 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "sharp": "0.31.3", "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", - "typeconfig": "2.2.7", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index 2576db38..404c1814 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -119,7 +119,6 @@ export class RenderingMWs { skipTags: {secret: true} as TAGS }) as PrivateConfigClass ); - console.log(message.result.Extensions.extensions); res.json(message); } diff --git a/src/backend/middlewares/SharingMWs.ts b/src/backend/middlewares/SharingMWs.ts index 943a3c1f..e4552561 100644 --- a/src/backend/middlewares/SharingMWs.ts +++ b/src/backend/middlewares/SharingMWs.ts @@ -171,7 +171,6 @@ export class SharingMWs { sharing, forceUpdate ); - console.log(req.resultPipe); return next(); } catch (err) { return next( diff --git a/src/backend/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index 0e7af1ca..7e8d37bc 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -1,12 +1,12 @@ import {NextFunction, Request, Response} from 'express'; 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 'typeconfig/node'; import {TAGS} from '../../../common/config/public/ClientConfig'; import {ObjectManagers} from '../../model/ObjectManagers'; import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper'; +import {Logger} from '../../Logger'; const LOG_TAG = '[SettingsMWs]'; @@ -21,8 +21,8 @@ export class SettingsMWs { */ public static async updateSettings(req: Request, res: Response, next: NextFunction): Promise { if ((typeof req.body === 'undefined') - || (typeof req.body.settings === 'undefined') - || (typeof req.body.settingsPath !== 'string')) { + || (typeof req.body.settings === 'undefined') + || (typeof req.body.settingsPath !== 'string')) { return next(new ErrorDTO(ErrorCodes.INPUT_ERROR, 'settings is needed')); } diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index 2b61ab2c..ae932d18 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -12,12 +12,13 @@ export class ExtensionConfigWrapper { static async original(): Promise { const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); try { - await pc.load(); + await pc.load(); // loading the basic configs but we do not know the extension config hierarchy yet if (ObjectManagers.isReady()) { for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); } } + await pc.load(); // loading the extension related configs } catch (e) { console.error('Error during loading original config. Reverting to defaults.'); console.error(e); @@ -58,13 +59,6 @@ export class ExtensionConfig implements IExtensionConfig { const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); const extConf = this.findConfig(config); - // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); extConf.configs = confTemplate; - console.log(((config as any).toJSON({attachState: true})).Extensions.extensions); - /* Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, - ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); - console.log(config.Extensions.extensions2[this.extensionFolder].configs); - config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; - console.log(config.Extensions.extensions2[this.extensionFolder].configs);*/ } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 795a6b09..f5256861 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -109,6 +109,10 @@ export class ExtensionManager implements IObjectManager { for (let i = 0; i < Config.Extensions.extensions.length; ++i) { const extFolder = Config.Extensions.extensions[i].path; let extName = extFolder; + + if(Config.Extensions.extensions[i].enabled === false){ + Logger.silly(LOG_TAG, `Skipping ${extFolder} initiation. Extension is disabled.`); + } const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); const serverExtPath = path.join(extPath, 'server.js'); const packageJsonPath = path.join(extPath, 'package.json'); diff --git a/src/backend/model/jobs/jobs/TopPickSendJob.ts b/src/backend/model/jobs/jobs/TopPickSendJob.ts index 2ba0450c..c748e19f 100644 --- a/src/backend/model/jobs/jobs/TopPickSendJob.ts +++ b/src/backend/model/jobs/jobs/TopPickSendJob.ts @@ -100,7 +100,6 @@ export class TopPickSendJob extends Job<{ arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index); this.Progress.Processed++; - // console.log(this.mediaList); return false; } diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts index 4d62097e..0efcbf03 100644 --- a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -19,6 +19,7 @@ export class ServerExtensionsEntryConfig { enabled: boolean = true; @ConfigProperty({ + readonly: true, tags: { name: $localize`Extension folder`, priority: ConfigPriority.underTheHood, diff --git a/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts b/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts index e304504d..ba8b4e9c 100644 --- a/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts +++ b/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts @@ -167,7 +167,6 @@ export class GallerySearchFieldBaseComponent 0, this.rawSearchText.length - token.current.length ) + item.queryHint; - console.log('aa'); this.onChange(); this.emptyAutoComplete(); } 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 4857ba25..548e898e 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 @@ -467,7 +467,6 @@ export class SettingsEntryComponent const reader = new FileReader(); reader.onload = () => { - console.log(reader.result); const parser = new DOMParser(); const doc = parser.parseFromString(reader.result as string, 'image/svg+xml'); try { From 8a8fc57c6736d568100ba1d3bc8dc34e01ad1c7e Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 24 Dec 2023 08:59:36 +0100 Subject: [PATCH 15/18] Refactor extension settings --- .../model/extension/ExtensionConfigWrapper.ts | 39 ++++++++-- .../model/extension/ExtensionManager.ts | 31 +++++--- .../model/extension/ExtensionObject.ts | 2 +- src/common/config/private/PrivateConfig.ts | 33 +------- .../{ => subconfigs}/MessagingConfig.ts | 2 +- .../subconfigs/ServerExtensionsConfig.ts | 78 +++++++++++++++++++ .../settings-entry.component.ts | 4 + 7 files changed, 139 insertions(+), 50 deletions(-) rename src/common/config/private/{ => subconfigs}/MessagingConfig.ts (97%) create mode 100644 src/common/config/private/subconfigs/ServerExtensionsConfig.ts diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index b11e5eaa..cad0ad24 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,9 +1,9 @@ -import {IConfigClass} from 'typeconfig/common'; +import {ConfigProperty, 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'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; /** * Wraps to original config and makes sure all extension related config is loaded @@ -29,11 +29,29 @@ export class ExtensionConfigWrapper { export class ExtensionConfig implements IExtensionConfig { public template: new() => C; - constructor(private readonly extensionId: string) { + constructor(private readonly extensionFolder: string) { + } + + private findConfig(config: PrivateConfigClass) { + let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); + if (!c) { + c = new ServerExtensionsEntryConfig(this.extensionFolder); + config.Extensions.extensions.push(c); + } + + if (!config.Extensions.extensions2[this.extensionFolder]) { + Object.defineProperty(config.Extensions.extensions2, this.extensionFolder, + ConfigProperty({type: ServerExtensionsEntryConfig})(config.Extensions.extensions2, this.extensionFolder)); + // config.Extensions.extensions2[this.extensionFolder] = c as any; + + config.Extensions.extensions2[this.extensionFolder] = c; + + } + return config.Extensions.extensions2[this.extensionFolder]; } public getConfig(): C { - return Config.Extensions.configs[this.extensionId] as C; + return this.findConfig(Config).configs as C; } public setTemplate(template: new() => C): void { @@ -45,8 +63,15 @@ export class ExtensionConfig implements IExtensionConfig { 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; + + const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); + const extConf = this.findConfig(config); + // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); + //extConf.configs = confTemplate; + Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, + ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); + console.log(config.Extensions.extensions2[this.extensionFolder].configs); + config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; + console.log(config.Extensions.extensions2[this.extensionFolder].configs); } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index f02dfb28..11f50aa4 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -12,6 +12,7 @@ import {SQLConnection} from '../database/SQLConnection'; import {ExtensionObject} from './ExtensionObject'; import {ExtensionDecoratorObject} from './ExtensionDecorator'; import * as util from 'util'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; // eslint-disable-next-line @typescript-eslint/no-var-requires const exec = util.promisify(require('child_process').exec); @@ -70,13 +71,23 @@ export class ExtensionManager implements IObjectManager { 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)); + + const extList = fs + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); + extList.sort(); + + // delete not existing extensions + Config.Extensions.extensions = Config.Extensions.extensions.filter(ec => extList.indexOf(ec.path) !== -1); + + // Add new extensions + const ePaths = Config.Extensions.extensions.map(ec => ec.path); + extList.filter(ep => ePaths.indexOf(ep) === -1).forEach(ep => + Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); + + Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.extensions.map(ec => ec.path))); } private createUniqueExtensionObject(name: string, folder: string): IExtensionObject { @@ -95,8 +106,8 @@ export class ExtensionManager implements IObjectManager { private async initExtensions() { - for (let i = 0; i < Config.Extensions.list.length; ++i) { - const extFolder = Config.Extensions.list[i]; + for (let i = 0; i < Config.Extensions.extensions.length; ++i) { + const extFolder = Config.Extensions.extensions[i].path; let extName = extFolder; const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); const serverExtPath = path.join(extPath, 'server.js'); @@ -122,7 +133,7 @@ export class ExtensionManager implements IObjectManager { 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)); + await ext?.init(this.createUniqueExtensionObject(extName, extFolder)); } } if (Config.Extensions.cleanUpUnusedTables) { diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index 3ff1aec6..254c8b9d 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -26,7 +26,7 @@ export class ExtensionObject implements IExtensionObject { events: IExtensionEvents) { const logger = createLoggerWrapper(`[Extension][${extensionId}]`); this._app = new ExtensionApp(); - this.config = new ExtensionConfig(extensionId); + this.config = new ExtensionConfig(folder); this.db = new ExtensionDB(logger); this.paths = ProjectPath; this.Logger = logger; diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 5375b9dd..721c02fc 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -11,7 +11,6 @@ import { } from '../../entities/job/JobScheduleDTO'; import { ClientConfig, - ClientExtensionsConfig, ClientGPXCompressingConfig, ClientMediaConfig, ClientMetaFileConfig, @@ -30,7 +29,8 @@ import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/Sear import {SortByTypes} from '../../entities/SortingMethods'; import {UserRoles} from '../../entities/UserDTO'; import {MediaPickDTO} from '../../entities/MediaPickDTO'; -import {MessagingConfig} from './MessagingConfig'; +import {ServerExtensionsConfig} from './subconfigs/ServerExtensionsConfig'; +import {MessagingConfig} from './subconfigs/MessagingConfig'; declare let $localize: (s: TemplateStringsArray) => string; @@ -966,35 +966,6 @@ export class ServerServiceConfig extends ClientServiceConfig { } -@SubConfigClass({softReadonly: true}) -export class ServerExtensionsConfig extends ClientExtensionsConfig { - - @ConfigProperty({ - tags: { - name: $localize`Extension folder`, - priority: ConfigPriority.underTheHood, - dockerSensitive: true - }, - description: $localize`Folder where the app stores the extensions. Extensions live in their sub-folders.`, - }) - folder: string = 'extensions'; - - @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}) diff --git a/src/common/config/private/MessagingConfig.ts b/src/common/config/private/subconfigs/MessagingConfig.ts similarity index 97% rename from src/common/config/private/MessagingConfig.ts rename to src/common/config/private/subconfigs/MessagingConfig.ts index 067a19f7..54ec6602 100644 --- a/src/common/config/private/MessagingConfig.ts +++ b/src/common/config/private/subconfigs/MessagingConfig.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; -import {ConfigPriority, TAGS} from '../public/ClientConfig'; +import {ConfigPriority, TAGS} from '../../public/ClientConfig'; declare let $localize: (s: TemplateStringsArray) => string; diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts new file mode 100644 index 00000000..22f7ad66 --- /dev/null +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-inferrable-types */ +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; +import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; +import {IConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass'; + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsEntryConfig { + + constructor(path: string = '') { + this.path = path; + } + + @ConfigProperty({ + tags: { + name: $localize`Enabled`, + priority: ConfigPriority.advanced, + }, + }) + enabled: boolean = true; + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + path: string = ''; + + @ConfigProperty({ + tags: { + name: $localize`Config`, + priority: ConfigPriority.advanced + } + }) + configs: IConfigClassPrivate; +} + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsConfig extends ClientExtensionsConfig { + + @ConfigProperty({ + tags: { + name: $localize`Extension folder`, + priority: ConfigPriority.underTheHood, + dockerSensitive: true + }, + description: $localize`Folder where the app stores all extensions. Individual extensions live in their own sub-folders.`, + }) + folder: string = 'extensions'; + + + @ConfigProperty({ + arrayType: ServerExtensionsEntryConfig, + tags: { + name: $localize`Installed extensions`, + priority: ConfigPriority.advanced + } + }) + extensions: ServerExtensionsEntryConfig[] = []; + + @ConfigProperty({ + tags: { + name: $localize`Installed extensions2`, + priority: ConfigPriority.advanced + } + }) + extensions2: 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; +} 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 333add00..1d235785 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 @@ -19,6 +19,7 @@ import {enumToTranslatedArray} from '../../../EnumTranslations'; import {BsModalService} from 'ngx-bootstrap/modal'; import {CustomSettingsEntries} from '../CustomSettingsEntries'; import {GroupByTypes, SortByTypes} from '../../../../../../common/entities/SortingMethods'; +import { ServerExtensionsEntryConfig } from '../../../../../../common/config/private/subconfigs/ServerExtensionsConfig'; interface IState { shouldHide(): boolean; @@ -232,6 +233,8 @@ export class SettingsEntryComponent this.arrayType = 'MapPathGroupThemeConfig'; } else if (this.state.arrayType === UserConfig) { this.arrayType = 'UserConfig'; + } else if (this.state.arrayType === ServerExtensionsEntryConfig) { + this.arrayType = 'ServerExtensionsEntryConfig'; } else if (this.state.arrayType === JobScheduleConfig) { this.arrayType = 'JobScheduleConfig'; } else { @@ -253,6 +256,7 @@ export class SettingsEntryComponent this.arrayType !== 'MapLayers' && this.arrayType !== 'NavigationLinkConfig' && this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && this.arrayType !== 'JobScheduleConfig' && this.arrayType !== 'UserConfig') { From 9172f89e78a9fc0638deab6d105288d00031195b Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Mon, 19 Feb 2024 22:15:57 +0100 Subject: [PATCH 16/18] Add basic extension UI #784 --- package-lock.json | 11 ++- package.json | 2 +- src/backend/middlewares/RenderingMWs.ts | 1 + .../model/extension/ExtensionConfigWrapper.ts | 37 +++---- .../model/extension/ExtensionManager.ts | 22 +++-- .../subconfigs/ServerExtensionsConfig.ts | 12 +-- src/common/config/public/ClientConfig.ts | 3 +- src/frontend/app/ui/admin/admin.component.ts | 16 ++-- .../template/CustomSettingsEntries.ts | 21 +++- .../settings-entry.component.html | 20 ++-- .../settings-entry.component.ts | 2 +- .../settings/template/template.component.html | 96 ++++++++++++------- .../settings/template/template.component.ts | 59 +++++++----- 13 files changed, 175 insertions(+), 127 deletions(-) diff --git a/package-lock.json b/package-lock.json index e71ebb12..c4770b33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "reflect-metadata": "0.1.13", "sharp": "0.31.3", "ts-node-iptc": "1.0.11", - "typeconfig": "2.1.2", + "typeconfig": "2.2.7", "typeorm": "0.3.12", "xml2js": "0.6.2" }, @@ -20361,8 +20361,9 @@ } }, "node_modules/typeconfig": { - "version": "2.1.2", - "license": "MIT", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", + "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", "dependencies": { "minimist": "1.2.8" } @@ -35279,7 +35280,9 @@ } }, "typeconfig": { - "version": "2.1.2", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", + "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", "requires": { "minimist": "1.2.8" } diff --git a/package.json b/package.json index 7219c2c0..d5b78dfb 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "reflect-metadata": "0.1.13", "sharp": "0.31.3", "ts-node-iptc": "1.0.11", - "typeconfig": "2.1.2", + "typeconfig": "2.2.7", "typeorm": "0.3.12", "xml2js": "0.6.2" }, diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index 404c1814..2576db38 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -119,6 +119,7 @@ export class RenderingMWs { skipTags: {secret: true} as TAGS }) as PrivateConfigClass ); + console.log(message.result.Extensions.extensions); res.json(message); } diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index cad0ad24..2b61ab2c 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -1,4 +1,4 @@ -import {ConfigProperty, IConfigClass} from 'typeconfig/common'; +import {IConfigClass} from 'typeconfig/common'; import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; import {ConfigClassBuilder} from 'typeconfig/node'; import {IExtensionConfig} from './IExtension'; @@ -32,22 +32,14 @@ export class ExtensionConfig implements IExtensionConfig { constructor(private readonly extensionFolder: string) { } - private findConfig(config: PrivateConfigClass) { - let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); - if (!c) { - c = new ServerExtensionsEntryConfig(this.extensionFolder); - config.Extensions.extensions.push(c); - } + private findConfig(config: PrivateConfigClass): ServerExtensionsEntryConfig { + let c = (config.Extensions.extensions || []).find(e => e.path === this.extensionFolder); + if (!c) { + c = new ServerExtensionsEntryConfig(this.extensionFolder); + config.Extensions.extensions.push(c); + } + return c; - if (!config.Extensions.extensions2[this.extensionFolder]) { - Object.defineProperty(config.Extensions.extensions2, this.extensionFolder, - ConfigProperty({type: ServerExtensionsEntryConfig})(config.Extensions.extensions2, this.extensionFolder)); - // config.Extensions.extensions2[this.extensionFolder] = c as any; - - config.Extensions.extensions2[this.extensionFolder] = c; - - } - return config.Extensions.extensions2[this.extensionFolder]; } public getConfig(): C { @@ -67,11 +59,12 @@ export class ExtensionConfig implements IExtensionConfig { const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); const extConf = this.findConfig(config); // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); - //extConf.configs = confTemplate; - Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, - ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); - console.log(config.Extensions.extensions2[this.extensionFolder].configs); - config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; - console.log(config.Extensions.extensions2[this.extensionFolder].configs); + extConf.configs = confTemplate; + console.log(((config as any).toJSON({attachState: true})).Extensions.extensions); + /* Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, + ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); + console.log(config.Extensions.extensions2[this.extensionFolder].configs); + config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; + console.log(config.Extensions.extensions2[this.extensionFolder].configs);*/ } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 11f50aa4..795a6b09 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -73,10 +73,10 @@ export class ExtensionManager implements IObjectManager { const extList = fs - .readdirSync(ProjectPath.ExtensionFolder) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); + .readdirSync(ProjectPath.ExtensionFolder) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); extList.sort(); // delete not existing extensions @@ -85,7 +85,7 @@ export class ExtensionManager implements IObjectManager { // Add new extensions const ePaths = Config.Extensions.extensions.map(ec => ec.path); extList.filter(ep => ePaths.indexOf(ep) === -1).forEach(ep => - Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); + Config.Extensions.extensions.push(new ServerExtensionsEntryConfig(ep))); Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.extensions.map(ec => ec.path))); } @@ -118,10 +118,14 @@ export class ExtensionManager implements IObjectManager { } 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 - }); + if (fs.existsSync(path.join(extPath, 'node_modules'))) { + Logger.debug(LOG_TAG, `node_modules folder exists. Skipping "npm install".`); + } else { + 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) { diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts index 22f7ad66..4d62097e 100644 --- a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-inferrable-types */ import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; -import {IConfigClassPrivate} from '../../../../../node_modules/typeconfig/src/decorators/class/IConfigClass'; +import {GenericConfigType} from 'typeconfig/src/GenericConfigType'; @SubConfigClass({softReadonly: true}) export class ServerExtensionsEntryConfig { @@ -28,12 +28,13 @@ export class ServerExtensionsEntryConfig { path: string = ''; @ConfigProperty({ + type: GenericConfigType, tags: { name: $localize`Config`, priority: ConfigPriority.advanced } }) - configs: IConfigClassPrivate; + configs: GenericConfigType; } @SubConfigClass({softReadonly: true}) @@ -59,13 +60,6 @@ export class ServerExtensionsConfig extends ClientExtensionsConfig { }) extensions: ServerExtensionsEntryConfig[] = []; - @ConfigProperty({ - tags: { - name: $localize`Installed extensions2`, - priority: ConfigPriority.advanced - } - }) - extensions2: Record = {}; @ConfigProperty({ tags: { diff --git a/src/common/config/public/ClientConfig.ts b/src/common/config/public/ClientConfig.ts index 3ac5b769..5d24388d 100644 --- a/src/common/config/public/ClientConfig.ts +++ b/src/common/config/public/ClientConfig.ts @@ -11,7 +11,7 @@ declare let $localize: (s: TemplateStringsArray) => string; if (typeof $localize === 'undefined') { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - global.$localize = (s) => s; + global.$localize = (s) => s[0]; } @@ -1034,7 +1034,6 @@ export class ThemesConfig { name: $localize`Selected theme css`, //this is a 'hack' to the UI settings. UI will only show the selected setting's css uiDisabled: (sb: ThemesConfig) => !sb.enabled, relevant: (c: ThemesConfig) => c.selectedTheme !== 'default', - uiType: 'SelectedThemeSettings' } as TAGS, description: $localize`Adds these css settings as it is to the end of the body tag of the page.` }) diff --git a/src/frontend/app/ui/admin/admin.component.ts b/src/frontend/app/ui/admin/admin.component.ts index 04feb789..5fb5d307 100644 --- a/src/frontend/app/ui/admin/admin.component.ts +++ b/src/frontend/app/ui/admin/admin.component.ts @@ -30,12 +30,12 @@ export class AdminComponent implements OnInit, AfterViewInit { public readonly configPaths: string[] = []; constructor( - private authService: AuthenticationService, - private navigation: NavigationService, - public viewportScroller: ViewportScroller, - public notificationService: NotificationService, - public settingsService: SettingsService, - private piTitleService: PiTitleService + private authService: AuthenticationService, + private navigation: NavigationService, + public viewportScroller: ViewportScroller, + public notificationService: NotificationService, + public settingsService: SettingsService, + private piTitleService: PiTitleService ) { this.configPriorities = enumToTranslatedArray(ConfigPriority); this.configStyles = enumToTranslatedArray(ConfigStyle); @@ -50,8 +50,8 @@ export class AdminComponent implements OnInit, AfterViewInit { ngOnInit(): void { if ( - !this.authService.isAuthenticated() || - this.authService.user.value.role < UserRoles.Admin + !this.authService.isAuthenticated() || + this.authService.user.value.role < UserRoles.Admin ) { this.navigation.toLogin(); return; diff --git a/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts b/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts index b2346b6c..d7470668 100644 --- a/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts +++ b/src/frontend/app/ui/settings/template/CustomSettingsEntries.ts @@ -1,5 +1,15 @@ import {propertyTypes} from 'typeconfig/common'; -import {ClientGroupingConfig, ClientSortingConfig, SVGIconConfig} from '../../../../../common/config/public/ClientConfig'; +import { + ClientGroupingConfig, + ClientSortingConfig, + MapLayers, + MapPathGroupConfig, + MapPathGroupThemeConfig, + NavigationLinkConfig, + SVGIconConfig, + ThemeConfig +} from '../../../../../common/config/public/ClientConfig'; +import {JobScheduleConfig, UserConfig} from '../../../../../common/config/private/PrivateConfig'; /** * Configuration in these class have a custom UI @@ -8,6 +18,13 @@ export class CustomSettingsEntries { public static readonly entries = [ {c: ClientSortingConfig, name: 'ClientSortingConfig'}, {c: ClientGroupingConfig, name: 'ClientGroupingConfig'}, + {c: MapLayers, name: 'MapLayers'}, + {c: JobScheduleConfig, name: 'JobScheduleConfig'}, + {c: UserConfig, name: 'UserConfig'}, + {c: NavigationLinkConfig, name: 'NavigationLinkConfig'}, + {c: MapPathGroupThemeConfig, name: 'MapPathGroupThemeConfig'}, + {c: MapPathGroupConfig, name: 'MapPathGroupConfig'}, + {c: ThemeConfig, name: 'ThemeConfig'}, {c: SVGIconConfig, name: 'SVGIconConfig'}, ]; @@ -46,7 +63,7 @@ export class CustomSettingsEntries { return cN; } - public static iS(s: { tags?: { uiType?: string }, type?: propertyTypes }) { + public static iS(s: { tags?: { uiType?: string }, type?: propertyTypes, arrayType?: propertyTypes }) { const c = this.getConfigName(s); return this.entries.findIndex(e => e.name == c) !== -1; } diff --git a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html index f6bdbfbe..a04a9801 100644 --- a/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html +++ b/src/frontend/app/ui/settings/template/settings-entry/settings-entry.component.html @@ -12,7 +12,6 @@ [hidden]="shouldHide">
-
- +
@@ -295,7 +296,7 @@ - +
@@ -357,7 +358,7 @@
- +
@@ -403,7 +404,7 @@
- +
@@ -468,7 +469,8 @@
- + +
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 1d235785..4857ba25 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 @@ -258,7 +258,7 @@ export class SettingsEntryComponent this.arrayType !== 'MapPathGroupConfig' && this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && - this.arrayType !== 'JobScheduleConfig' && + this.arrayType !== 'JobScheduleConfig-Array' && this.arrayType !== 'UserConfig') { this.uiType = 'StringInput'; } diff --git a/src/frontend/app/ui/settings/template/template.component.html b/src/frontend/app/ui/settings/template/template.component.html index 35f7fd8d..35af7b9b 100644 --- a/src/frontend/app/ui/settings/template/template.component.html +++ b/src/frontend/app/ui/settings/template/template.component.html @@ -3,7 +3,7 @@
- {{Name}} + {{ Name }}
@@ -22,7 +22,7 @@
- + - {{Name}} config is not supported with these settings. + {{ Name }} config is not supported with these settings.
@@ -72,46 +72,71 @@ let-confPath="confPath"> - - - -
-
-
- - {{rStates?.value.__state[ck].tags?.name || ck}} -
- + + +
+
+
{{ rStates?.value.__state[ck].tags?.name || ck }}
+
+
+
+
+ +
- -
-
-
{{rStates?.value.__state[ck].tags?.name || ck}}
-
-
-
+ + + + + + + + + + +
+
+
+ + {{ rStates?.value.__state[ck].tags?.name || ck }} +
+
-
- -
+ + +
+
+
{{ rStates?.value.__state[ck].tags?.name || ck }}
+
+
+
+
+
+
+ +
+
@@ -125,12 +150,14 @@ >
+
+
diff --git a/src/frontend/app/ui/settings/template/template.component.ts b/src/frontend/app/ui/settings/template/template.component.ts index 6c115e31..70aa8f9c 100644 --- a/src/frontend/app/ui/settings/template/template.component.ts +++ b/src/frontend/app/ui/settings/template/template.component.ts @@ -79,11 +79,11 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting public readonly ConfigStyle = ConfigStyle; constructor( - protected authService: AuthenticationService, - private navigation: NavigationService, - protected notification: NotificationService, - public settingsService: SettingsService, - public jobsService: ScheduledJobsService, + protected authService: AuthenticationService, + private navigation: NavigationService, + protected notification: NotificationService, + public settingsService: SettingsService, + public jobsService: ScheduledJobsService, ) { } @@ -97,7 +97,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting this.nestedConfigs = []; for (const key of this.getKeys(this.states)) { if (this.states.value.__state[key].isConfigType && - this.states?.value.__state[key].tags?.uiIcon) { + this.states?.value.__state[key].tags?.uiIcon) { this.nestedConfigs.push({ id: this.ConfigPath + '.' + key, name: this.states?.value.__state[key].tags?.name, @@ -112,8 +112,8 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting ngOnInit(): void { if ( - !this.authService.isAuthenticated() || - this.authService.user.value.role < UserRoles.Admin + !this.authService.isAuthenticated() || + this.authService.user.value.role < UserRoles.Admin ) { this.navigation.toLogin(); return; @@ -143,7 +143,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting if (sliceFN) { this.sliceFN = sliceFN; this.settingsSubscription = this.settingsService.settings.subscribe( - this.onNewSettings + this.onNewSettings ); } } @@ -171,31 +171,31 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } if (state.tags && - ((state.tags.relevant && !state.tags.relevant(parent.value)) - || state.tags.secret)) { + ((state.tags.relevant && !state.tags.relevant(parent.value)) + || state.tags.secret)) { return true; } // if all sub elements are hidden, hide the parent too. if (state.isConfigType) { if (state.value && state.value.__state && - Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) { + Object.keys(state.value.__state).findIndex(k => !st.value.__state[k].shouldHide()) === -1) { return true; } } const forcedVisibility = !(state.tags?.priority > this.settingsService.configPriority || - //if this value should not change in Docker, lets hide it - (this.settingsService.configPriority === ConfigPriority.basic && - state.tags?.dockerSensitive && this.settingsService.settings.value.Environment.isDocker)); + //if this value should not change in Docker, lets hide it + (this.settingsService.configPriority === ConfigPriority.basic && + state.tags?.dockerSensitive && this.settingsService.settings.value.Environment.isDocker)); if (state.isConfigArrayType) { for (let i = 0; i < state.value?.length; ++i) { for (const k of Object.keys(state.value[i].__state)) { if (!Utils.equalsFilter( - state.value[i]?.__state[k]?.value, - state.default[i] ? state.default[i][k] : undefined, - ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { + state.value[i]?.__state[k]?.value, + state.default[i] ? state.default[i][k] : undefined, + ['default', '__propPath', '__created', '__prototype', '__rootConfig'])) { return false; } @@ -206,10 +206,10 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return (!forcedVisibility && - Utils.equalsFilter(state.value, state.default, - ['__propPath', '__created', '__prototype', '__rootConfig']) && - Utils.equalsFilter(state.original, state.default, - ['__propPath', '__created', '__prototype', '__rootConfig'])); + Utils.equalsFilter(state.value, state.default, + ['__propPath', '__created', '__prototype', '__rootConfig']) && + Utils.equalsFilter(state.original, state.default, + ['__propPath', '__created', '__prototype', '__rootConfig'])); }; }; @@ -246,7 +246,7 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } if (typeof state.original === 'object') { return Utils.equalsFilter(state.value, state.original, - ['__propPath', '__created', '__prototype', '__rootConfig', '__state']); + ['__propPath', '__created', '__prototype', '__rootConfig', '__state']); } if (typeof state.original !== 'undefined') { return state.value === state.original; @@ -271,10 +271,18 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting this.getSettings(); } + /** + * main template should list it + * @param c + */ isExpandableConfig(c: ConfigState) { return c.isConfigType && !CustomSettingsEntries.iS(c); } + isExpandableArrayConfig(c: ConfigState) { + return c.isConfigArrayType && !CustomSettingsEntries.iS(c); + } + public async save(): Promise { this.inProgress = true; @@ -284,8 +292,8 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting await this.settingsService.updateSettings(state, this.ConfigPath); await this.getSettings(); this.notification.success( - this.Name + ' ' + $localize`settings saved`, - $localize`Success` + this.Name + ' ' + $localize`settings saved`, + $localize`Success` ); this.inProgress = false; return true; @@ -328,7 +336,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return 1; } return (s[a].tags?.name as string || a).localeCompare(s[b].tags?.name || b); - }); states.keys = keys; return states.keys; From 1502e8015042072ff6a7ae5be069a4065213c626 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 2 Mar 2024 22:18:31 +0100 Subject: [PATCH 17/18] Add basic extension UI #784 --- package-lock.json | 14 +++++++------- package.json | 2 +- src/backend/middlewares/RenderingMWs.ts | 1 - src/backend/middlewares/SharingMWs.ts | 1 - src/backend/middlewares/admin/SettingsMWs.ts | 6 +++--- .../model/extension/ExtensionConfigWrapper.ts | 10 ++-------- src/backend/model/extension/ExtensionManager.ts | 4 ++++ src/backend/model/jobs/jobs/TopPickSendJob.ts | 1 - .../private/subconfigs/ServerExtensionsConfig.ts | 1 + .../search-field-base.gallery.component.ts | 1 - .../settings-entry/settings-entry.component.ts | 1 - 11 files changed, 18 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index c4770b33..06dc3a97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "reflect-metadata": "0.1.13", "sharp": "0.31.3", "ts-node-iptc": "1.0.11", - "typeconfig": "2.2.7", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, @@ -20361,9 +20361,9 @@ } }, "node_modules/typeconfig": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", - "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.11.tgz", + "integrity": "sha512-Knj+1kbIJ4zOZlUm2TPSWZUoiOW4txrmPyf6oyuBhaDQDlGxpSL5jobF3vVV9mZElK1V3ZQVeTgvGaiDyeT8mQ==", "dependencies": { "minimist": "1.2.8" } @@ -35280,9 +35280,9 @@ } }, "typeconfig": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.7.tgz", - "integrity": "sha512-xxMJky/XUsmWss8HM99uPeN+sZYF67AAht3Gajtnbp4k5bxBwplnahU+1N1GUKhmvFuqQoIQbiXsu9WpvznI1g==", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.11.tgz", + "integrity": "sha512-Knj+1kbIJ4zOZlUm2TPSWZUoiOW4txrmPyf6oyuBhaDQDlGxpSL5jobF3vVV9mZElK1V3ZQVeTgvGaiDyeT8mQ==", "requires": { "minimist": "1.2.8" } diff --git a/package.json b/package.json index d5b78dfb..65e65868 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "reflect-metadata": "0.1.13", "sharp": "0.31.3", "ts-node-iptc": "1.0.11", - "typeconfig": "2.2.7", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, diff --git a/src/backend/middlewares/RenderingMWs.ts b/src/backend/middlewares/RenderingMWs.ts index 2576db38..404c1814 100644 --- a/src/backend/middlewares/RenderingMWs.ts +++ b/src/backend/middlewares/RenderingMWs.ts @@ -119,7 +119,6 @@ export class RenderingMWs { skipTags: {secret: true} as TAGS }) as PrivateConfigClass ); - console.log(message.result.Extensions.extensions); res.json(message); } diff --git a/src/backend/middlewares/SharingMWs.ts b/src/backend/middlewares/SharingMWs.ts index 943a3c1f..e4552561 100644 --- a/src/backend/middlewares/SharingMWs.ts +++ b/src/backend/middlewares/SharingMWs.ts @@ -171,7 +171,6 @@ export class SharingMWs { sharing, forceUpdate ); - console.log(req.resultPipe); return next(); } catch (err) { return next( diff --git a/src/backend/middlewares/admin/SettingsMWs.ts b/src/backend/middlewares/admin/SettingsMWs.ts index 0e7af1ca..7e8d37bc 100644 --- a/src/backend/middlewares/admin/SettingsMWs.ts +++ b/src/backend/middlewares/admin/SettingsMWs.ts @@ -1,12 +1,12 @@ import {NextFunction, Request, Response} from 'express'; 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 'typeconfig/node'; import {TAGS} from '../../../common/config/public/ClientConfig'; import {ObjectManagers} from '../../model/ObjectManagers'; import {ExtensionConfigWrapper} from '../../model/extension/ExtensionConfigWrapper'; +import {Logger} from '../../Logger'; const LOG_TAG = '[SettingsMWs]'; @@ -21,8 +21,8 @@ export class SettingsMWs { */ public static async updateSettings(req: Request, res: Response, next: NextFunction): Promise { if ((typeof req.body === 'undefined') - || (typeof req.body.settings === 'undefined') - || (typeof req.body.settingsPath !== 'string')) { + || (typeof req.body.settings === 'undefined') + || (typeof req.body.settingsPath !== 'string')) { return next(new ErrorDTO(ErrorCodes.INPUT_ERROR, 'settings is needed')); } diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index 2b61ab2c..ae932d18 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -12,12 +12,13 @@ export class ExtensionConfigWrapper { static async original(): Promise { const pc = ConfigClassBuilder.attachPrivateInterface(new PrivateConfigClass()); try { - await pc.load(); + await pc.load(); // loading the basic configs but we do not know the extension config hierarchy yet if (ObjectManagers.isReady()) { for (const ext of Object.values(ObjectManagers.getInstance().ExtensionManager.extObjects)) { ext.config.loadToConfig(ConfigClassBuilder.attachPrivateInterface(pc)); } } + await pc.load(); // loading the extension related configs } catch (e) { console.error('Error during loading original config. Reverting to defaults.'); console.error(e); @@ -58,13 +59,6 @@ export class ExtensionConfig implements IExtensionConfig { const confTemplate = ConfigClassBuilder.attachPrivateInterface(new this.template()); const extConf = this.findConfig(config); - // confTemplate.__loadJSONObject(Utils.clone(extConf.configs || {})); extConf.configs = confTemplate; - console.log(((config as any).toJSON({attachState: true})).Extensions.extensions); - /* Object.defineProperty(config.Extensions.extensions2[this.extensionFolder].configs, this.extensionFolder, - ConfigProperty({type: this.template})(config.Extensions.extensions2[this.extensionFolder], this.extensionFolder)); - console.log(config.Extensions.extensions2[this.extensionFolder].configs); - config.Extensions.extensions2[this.extensionFolder].configs = confTemplate as any; - console.log(config.Extensions.extensions2[this.extensionFolder].configs);*/ } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 795a6b09..f5256861 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -109,6 +109,10 @@ export class ExtensionManager implements IObjectManager { for (let i = 0; i < Config.Extensions.extensions.length; ++i) { const extFolder = Config.Extensions.extensions[i].path; let extName = extFolder; + + if(Config.Extensions.extensions[i].enabled === false){ + Logger.silly(LOG_TAG, `Skipping ${extFolder} initiation. Extension is disabled.`); + } const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); const serverExtPath = path.join(extPath, 'server.js'); const packageJsonPath = path.join(extPath, 'package.json'); diff --git a/src/backend/model/jobs/jobs/TopPickSendJob.ts b/src/backend/model/jobs/jobs/TopPickSendJob.ts index 2ba0450c..c748e19f 100644 --- a/src/backend/model/jobs/jobs/TopPickSendJob.ts +++ b/src/backend/model/jobs/jobs/TopPickSendJob.ts @@ -100,7 +100,6 @@ export class TopPickSendJob extends Job<{ arr.findIndex(m => MediaDTOUtils.equals(m, value)) === index); this.Progress.Processed++; - // console.log(this.mediaList); return false; } diff --git a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts index 4d62097e..0efcbf03 100644 --- a/src/common/config/private/subconfigs/ServerExtensionsConfig.ts +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -19,6 +19,7 @@ export class ServerExtensionsEntryConfig { enabled: boolean = true; @ConfigProperty({ + readonly: true, tags: { name: $localize`Extension folder`, priority: ConfigPriority.underTheHood, diff --git a/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts b/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts index e304504d..ba8b4e9c 100644 --- a/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts +++ b/src/frontend/app/ui/gallery/search/search-field-base/search-field-base.gallery.component.ts @@ -167,7 +167,6 @@ export class GallerySearchFieldBaseComponent 0, this.rawSearchText.length - token.current.length ) + item.queryHint; - console.log('aa'); this.onChange(); this.emptyAutoComplete(); } 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 4857ba25..548e898e 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 @@ -467,7 +467,6 @@ export class SettingsEntryComponent const reader = new FileReader(); reader.onload = () => { - console.log(reader.result); const parser = new DOMParser(); const doc = parser.parseFromString(reader.result as string, 'image/svg+xml'); try { From 745e486e1c20a388a2b6f94fb34502a0499d99c6 Mon Sep 17 00:00:00 2001 From: Matthew Blythe Date: Sun, 3 Mar 2024 01:19:21 -0700 Subject: [PATCH 18/18] Add logging elision Allow for anonymous functions in logging calls. The function is only called if the message is logged. (e.g. if the verbosity is turned up high enough.) This allows for expensive operations to be avoided in cases where the logging won't happen. The idea is that this provides a performance benefit. I don't know how "expensive" an operation must be to actually realize a performance benefit, though. I added an example in server.ts... It's probably "expensive" to dump the configuration to JSON, then stringify that JSON for logging. --- src/backend/Logger.ts | 36 +++++++++++++++++++++--------------- src/backend/server.ts | 4 ++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/backend/Logger.ts b/src/backend/Logger.ts index 7d860178..3cb26270 100644 --- a/src/backend/Logger.ts +++ b/src/backend/Logger.ts @@ -1,7 +1,7 @@ import {Config} from '../common/config/private/Config'; import {LogLevel} from '../common/config/private/PrivateConfig'; -export type logFN = (...args: (string | number)[]) => void; +export type logFN = (...args: (string | number | (() => string))[]) => void; const forcedDebug = process.env['NODE_ENV'] === 'debug'; @@ -11,7 +11,8 @@ if (forcedDebug === true) { ); } -export type LoggerFunction = (...args: (string | number)[]) => void; +export type LoggerArgs = (string | number | (() => string)) +export type LoggerFunction = (...args: LoggerArgs[]) => void; export interface ILogger { silly: LoggerFunction; @@ -23,67 +24,67 @@ export interface ILogger { } export const createLoggerWrapper = (TAG: string): ILogger => ({ - silly: (...args: (string | number)[]) => { + silly: (...args: LoggerArgs[]) => { Logger.silly(TAG, ...args); }, - debug: (...args: (string | number)[]) => { + debug: (...args: LoggerArgs[]) => { Logger.debug(TAG, ...args); }, - verbose: (...args: (string | number)[]) => { + verbose: (...args: LoggerArgs[]) => { Logger.verbose(TAG, ...args); }, - info: (...args: (string | number)[]) => { + info: (...args: LoggerArgs[]) => { Logger.info(TAG, ...args); }, - warn: (...args: (string | number)[]) => { + warn: (...args: LoggerArgs[]) => { Logger.warn(TAG, ...args); }, - error: (...args: (string | number)[]) => { + error: (...args: LoggerArgs[]) => { Logger.error(TAG, ...args); } }); export class Logger { - public static silly(...args: (string | number)[]): void { + public static silly(...args: LoggerArgs[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.silly) { return; } Logger.log(`[\x1b[35mSILLY\x1b[0m]`, ...args); } - public static debug(...args: (string | number)[]): void { + public static debug(...args: LoggerArgs[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.debug) { return; } Logger.log(`[\x1b[34mDEBUG\x1b[0m]`, ...args); } - public static verbose(...args: (string | number)[]): void { + public static verbose(...args: LoggerArgs[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.verbose) { return; } Logger.log(`[\x1b[36mVERBS\x1b[0m]`, ...args); } - public static info(...args: (string | number)[]): void { + public static info(...args: LoggerArgs[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.info) { return; } Logger.log(`[\x1b[32mINFO_\x1b[0m]`, ...args); } - public static warn(...args: (string | number)[]): void { + public static warn(...args: LoggerArgs[]): void { if (!forcedDebug && Config.Server.Log.level < LogLevel.warn) { return; } Logger.log(`[\x1b[33mWARN_\x1b[0m]`, ...args); } - public static error(...args: (string | number)[]): void { + public static error(...args: LoggerArgs[]): void { Logger.log(`[\x1b[31mERROR\x1b[0m]`, ...args); } - private static log(tag: string, ...args: (string | number)[]): void { + private static log(tag: string, ...args: LoggerArgs[]): void { const date = new Date().toLocaleString(); let LOG_TAG = ''; if ( @@ -95,6 +96,11 @@ export class Logger { LOG_TAG = args[0]; args.shift(); } + args.forEach((element:LoggerArgs, index:number) => { + if(typeof element === "function"){ + args[index] = element(); //execute function, put resulting string in the array + } + }); console.log(date + tag + LOG_TAG, ...args); } } diff --git a/src/backend/server.ts b/src/backend/server.ts index f77c132e..a34b3643 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -67,14 +67,14 @@ export class Server { await ConfigDiagnostics.runDiagnostics(); Logger.verbose( LOG_TAG, - 'using config from ' + + () => 'using config from ' + ( ConfigClassBuilder.attachPrivateInterface(Config) .__options as ConfigClassOptions ).configPath + ':' ); - Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), (k, v) => { + 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) + '...';

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 afHe(70nV>}a3N0)*fO2C?*&o$&;QxG-M5DT literal 0 HcmV?d00001 diff --git a/demo/images/timestamps/newyear_sydney.jpg b/demo/images/timestamps/newyear_sydney.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8576e37f56e54013488e138305b897a28662ffce GIT binary patch literal 67879 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