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/demo/images/dupl/big_ben_only_time.jpg b/demo/images/dupl/big_ben_only_time.jpg new file mode 100644 index 00000000..88d3bc35 Binary files /dev/null and b/demo/images/dupl/big_ben_only_time.jpg differ diff --git a/demo/images/dupl/sydney_opera_house.jpg b/demo/images/dupl/sydney_opera_house.jpg new file mode 100644 index 00000000..b595487e Binary files /dev/null and b/demo/images/dupl/sydney_opera_house.jpg differ diff --git a/demo/images/timestamps/big_ben.jpg b/demo/images/timestamps/big_ben.jpg new file mode 100644 index 00000000..0649be53 Binary files /dev/null and b/demo/images/timestamps/big_ben.jpg differ 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 00000000..48897cf1 Binary files /dev/null and b/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg differ diff --git a/demo/images/timestamps/big_ben_only_time.jpg b/demo/images/timestamps/big_ben_only_time.jpg new file mode 100644 index 00000000..88d3bc35 Binary files /dev/null and b/demo/images/timestamps/big_ben_only_time.jpg differ diff --git a/demo/images/timestamps/newyear_london.jpg b/demo/images/timestamps/newyear_london.jpg new file mode 100644 index 00000000..5a211853 Binary files /dev/null and b/demo/images/timestamps/newyear_london.jpg differ diff --git a/demo/images/timestamps/newyear_sydney.jpg b/demo/images/timestamps/newyear_sydney.jpg new file mode 100644 index 00000000..8576e37f Binary files /dev/null and b/demo/images/timestamps/newyear_sydney.jpg differ diff --git a/demo/images/timestamps/sydney_opera_house.jpg b/demo/images/timestamps/sydney_opera_house.jpg new file mode 100644 index 00000000..b595487e Binary files /dev/null and b/demo/images/timestamps/sydney_opera_house.jpg differ 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 00000000..18433139 Binary files /dev/null and b/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg differ diff --git a/package-lock.json b/package-lock.json index db09d172..06dc3a97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,8 @@ "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", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, @@ -20052,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, @@ -20368,8 +20361,9 @@ } }, "node_modules/typeconfig": { - "version": "2.1.2", - "license": "MIT", + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.2.11.tgz", + "integrity": "sha512-Knj+1kbIJ4zOZlUm2TPSWZUoiOW4txrmPyf6oyuBhaDQDlGxpSL5jobF3vVV9mZElK1V3ZQVeTgvGaiDyeT8mQ==", "dependencies": { "minimist": "1.2.8" } @@ -35085,12 +35079,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, @@ -35292,7 +35280,9 @@ } }, "typeconfig": { - "version": "2.1.2", + "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 f3dac2e6..65e65868 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,8 @@ "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", + "typeconfig": "2.2.11", "typeorm": "0.3.12", "xml2js": "0.6.2" }, 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/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/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/backend/model/database/enitites/MediaEntity.ts b/src/backend/model/database/enitites/MediaEntity.ts index 52bbf600..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') @@ -105,6 +106,15 @@ export class MediaMetadataEntity implements MediaMetadata { }) @Index() creationDate: number; + + @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; + @Column('int', {unsigned: true}) fileSize: number; diff --git a/src/backend/model/extension/ExtensionConfigWrapper.ts b/src/backend/model/extension/ExtensionConfigWrapper.ts index b11e5eaa..ae932d18 100644 --- a/src/backend/model/extension/ExtensionConfigWrapper.ts +++ b/src/backend/model/extension/ExtensionConfigWrapper.ts @@ -2,8 +2,8 @@ import {IConfigClass} from 'typeconfig/common'; import {Config, PrivateConfigClass} from '../../../common/config/private/Config'; import {ConfigClassBuilder} from 'typeconfig/node'; import {IExtensionConfig} from './IExtension'; -import {Utils} from '../../../common/Utils'; import {ObjectManagers} from '../ObjectManagers'; +import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; /** * Wraps to original config and makes sure all extension related config is loaded @@ -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); @@ -29,11 +30,21 @@ 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): 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; + } 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 +56,9 @@ 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); + extConf.configs = confTemplate; } } diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index f02dfb28..f5256861 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 + + const extList = 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)); + 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,9 +106,13 @@ 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; + + 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'); @@ -107,10 +122,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) { @@ -122,7 +141,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/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 0f6af8fb..c4afbe8a 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -1,579 +1,667 @@ -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 { ExifParserFactory, OrientationTypes } from 'ts-exif-parser'; -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(); - } 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.join(path.parse(fullPath).dir, 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 = []; - } - let keywords = (sidecarData as SideCar).dc.subject || []; - if (typeof keywords === 'string') { - keywords = [keywords]; - } - for (const kw of keywords) { - 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: 1, height: 1}, - 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: 1, height: 1}, - creationDate: 0, - fileSize: 0, - }; - 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 { - 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}; - } - } - - try { - 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); - } - - 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; - } - } - 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); - } - } - } - 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) { - // noinspection JSSuspiciousNameCombination - const height = metadata.size.width; - // noinspection JSSuspiciousNameCombination - metadata.size.width = metadata.size.height; - metadata.size.height = height; - } - - 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 (OrientationTypes.BOTTOM_LEFT < orientation) { - [x, y] = [y, x]; - [w, h] = [h, w]; - } - let swapX = 0; - let swapY = 0; - switch (orientation) { - case OrientationTypes.TOP_RIGHT: - case OrientationTypes.RIGHT_TOP: - swapX = 1; - break; - case OrientationTypes.BOTTOM_RIGHT: - case OrientationTypes.RIGHT_BOTTOM: - swapX = 1; - swapY = 1; - break; - case OrientationTypes.BOTTOM_LEFT: - case OrientationTypes.LEFT_BOTTOM: - 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 - } - - try { - // search for sidecar and merge metadata - const fullPathWithoutExt = path.join(path.parse(fullPath).dir, 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 = []; - } - let keywords = (sidecarData as SideCar).dc.subject || []; - if (typeof keywords === 'string') { - keywords = [keywords]; - } - for (const kw of keywords) { - 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.join(path.parse(fullPath).dir, 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 = []; + } + let keywords = (sidecarData as SideCar).dc.subject || []; + if (typeof keywords === 'string') { + keywords = [keywords]; + } + for (const kw of keywords) { + 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; + return Utils.getOffsetString(offsetMinutes); + } 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.join(path.parse(fullPath).dir, 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 = []; + } + let keywords = (sidecarData as SideCar).dc.subject || []; + if (typeof keywords === 'string') { + keywords = [keywords]; + } + for (const kw of keywords) { + 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/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/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/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) + '...'; diff --git a/src/common/Utils.ts b/src/common/Utils.ts index ceef8811..e0a475d6 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -110,6 +110,43 @@ 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 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)) { + 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; + } + } + static renderDataSize(size: number): string { const postFixes = ['B', 'KB', 'MB', 'GB', 'TB']; let index = 0; 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..0efcbf03 --- /dev/null +++ b/src/common/config/private/subconfigs/ServerExtensionsConfig.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-inferrable-types */ +import {ConfigProperty, SubConfigClass} from 'typeconfig/common'; +import {ClientExtensionsConfig, ConfigPriority, TAGS} from '../../public/ClientConfig'; +import {GenericConfigType} from 'typeconfig/src/GenericConfigType'; + +@SubConfigClass({softReadonly: true}) +export class ServerExtensionsEntryConfig { + + constructor(path: string = '') { + this.path = path; + } + + @ConfigProperty({ + tags: { + name: $localize`Enabled`, + priority: ConfigPriority.advanced, + }, + }) + enabled: boolean = true; + + @ConfigProperty({ + readonly: true, + 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({ + type: GenericConfigType, + tags: { + name: $localize`Config`, + priority: ConfigPriority.advanced + } + }) + configs: GenericConfigType; +} + +@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`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/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/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/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/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/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}}
- {{media.metadata.creationDate | date}}, {{media.metadata.creationDate | date:'mediumTime'}} + {{ media.metadata.creationDate | date : 'longDate' : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}, + {{ media.metadata.creationDate | date : (media.metadata.creationDateOffset ? 'HH:mm:ss ZZZZZ' : 'HH:mm:ss') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
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..5638adc1 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(); } } @@ -496,7 +508,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 || @@ -537,4 +549,3 @@ export class ControlsLightboxComponent implements OnDestroy, OnInit, OnChanges { } } - 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(); 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/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 333add00..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 @@ -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,8 +256,9 @@ export class SettingsEntryComponent this.arrayType !== 'MapLayers' && this.arrayType !== 'NavigationLinkConfig' && this.arrayType !== 'MapPathGroupConfig' && + this.arrayType !== 'ServerExtensionsEntryConfig' && this.arrayType !== 'MapPathGroupThemeConfig' && - this.arrayType !== 'JobScheduleConfig' && + this.arrayType !== 'JobScheduleConfig-Array' && this.arrayType !== 'UserConfig') { this.uiType = 'StringInput'; } @@ -463,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 { 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; 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, 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..0edd5661 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": 1544748139000 } diff --git a/test/backend/assets/timestamps/big_ben.jpg b/test/backend/assets/timestamps/big_ben.jpg new file mode 100644 index 00000000..0649be53 Binary files /dev/null and b/test/backend/assets/timestamps/big_ben.jpg differ 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 00000000..48897cf1 Binary files /dev/null and b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg differ diff --git a/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json new file mode 100644 index 00000000..28844e96 --- /dev/null +++ b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686141955000, + "creationDateOffset": "+01:00", + "fileSize": 18663, + "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_only_time.jpg b/test/backend/assets/timestamps/big_ben_only_time.jpg new file mode 100644 index 00000000..88d3bc35 Binary files /dev/null and b/test/backend/assets/timestamps/big_ben_only_time.jpg differ 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 00000000..b595487e Binary files /dev/null and b/test/backend/assets/timestamps/sydney_opera_house.jpg differ 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 00000000..18433139 Binary files /dev/null and b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg differ 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..7a231aeb --- /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": 22641, + "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 e3465079..92997ba8 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); + }); it('should load sidecar file with file extension for video', async () => { const data = await MetadataLoader.loadVideoMetadata(path.join(__dirname, '/../../../assets/sidecar/bunny_1sec.mp4')); const expected = require(path.join(__dirname, '/../../../assets/sidecar/bunny_1sec.mp4.json'));