1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2024-12-23 01:27:14 +02:00

Merge branch 'master' into issue838

# Conflicts:
#	src/backend/model/fileaccess/MetadataLoader.ts
#	test/backend/unit/model/threading/MetaDataLoader.spec.ts
This commit is contained in:
kagahd 2024-03-04 11:55:04 +01:00
commit 0f0a66c05d
No known key found for this signature in database
GPG Key ID: C7C8777FDE0053DF
66 changed files with 1243 additions and 783 deletions

3
.gitignore vendored
View File

@ -39,4 +39,5 @@ test.*
*.sublime-workspace
.DS_Store
/coverage/
.nyc_output/
.nyc_output/
.vscode*

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

24
package-lock.json generated
View File

@ -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"
}

View File

@ -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"
},

View File

@ -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);
}
}

View File

@ -171,7 +171,6 @@ export class SharingMWs {
sharing,
forceUpdate
);
console.log(req.resultPipe);
return next();
} catch (err) {
return next(

View File

@ -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<void> {
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'));
}

View File

@ -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);

View File

@ -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;

View File

@ -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<PrivateConfigClass & IConfigClass> {
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<C> implements IExtensionConfig<C> {
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<C> implements IExtensionConfig<C> {
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;
}
}

View File

@ -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<unknown> {
@ -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) {

View File

@ -26,7 +26,7 @@ export class ExtensionObject<C> implements IExtensionObject<C> {
events: IExtensionEvents) {
const logger = createLoggerWrapper(`[Extension][${extensionId}]`);
this._app = new ExtensionApp();
this.config = new ExtensionConfig<C>(extensionId);
this.config = new ExtensionConfig<C>(folder);
this.db = new ExtensionDB(logger);
this.paths = ProjectPath;
this.Logger = logger;

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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,

View File

@ -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<ServerConfig>
).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) + '...';

View File

@ -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;

View File

@ -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<TAGS>({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<string, unknown> = {};
@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})

View File

@ -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;

View File

@ -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<TAGS>({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<TAGS>({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;
}

View File

@ -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.`
})

View File

@ -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

View File

@ -17,6 +17,7 @@ export interface MediaMetadata {
size: MediaDimension;
creationDate: number;
fileSize: number;
creationDateOffset?: string;
keywords?: string[];
rating?: RatingTypes;
title?: string;

View File

@ -33,6 +33,7 @@ export interface PhotoMetadata extends MediaMetadata {
positionData?: PositionMetaData;
size: MediaDimension;
creationDate: number;
creationDateOffset?: string;
fileSize: number;
faces?: FaceRegion[];
}

View File

@ -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;

View File

@ -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;

View File

@ -24,7 +24,8 @@
{{media.metadata.fileSize | fileSize}}
</div>
<div class="col-3 align-self-center" [title]="media.metadata.creationDate">
{{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') }}
</div>
</a>
</div>

View File

@ -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;

View File

@ -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 {
}
}

View File

@ -54,10 +54,10 @@
</div>
<div class="col-11">
<div class="details-main">
{{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : 'UTC' }}
{{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
</div>
<div class="details-sub text-secondary row">
<div class="col-12">{{ media.metadata.creationDate | date : 'EEEE, HH:mm:ss' : 'UTC' }}</div>
<div class="col-12">{{ media.metadata.creationDate | date : (media.metadata.creationDateOffset ? 'EEEE, HH:mm:ss ZZZZZ' : 'EEEE, HH:mm:ss') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}</div>
</div>
</div>
</div>

View File

@ -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)
);
}

View File

@ -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();

View File

@ -167,7 +167,6 @@ export class GallerySearchFieldBaseComponent
0,
this.rawSearchText.length - token.current.length
) + item.queryHint;
console.log('aa');
this.onChange();
this.emptyAutoComplete();
}

View File

@ -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;
}

View File

@ -12,7 +12,6 @@
[hidden]="shouldHide">
<label class="col-sm-3 col-md-2 control-label" [for]="idName">{{name}}</label>
<div class="col-12 col-sm">
<div class="input-group" [ngSwitch]="uiType">
<app-gallery-search-field
*ngSwitchCase="'SearchQuery'"
@ -29,7 +28,9 @@
style="flex: 1"
*ngSwitchCase="'StringInput'">
<input
[type]="HTMLInputType" [min]="state.min" [max]="state.max" class="form-control"
[type]="HTMLInputType"
[min]="state.min"
[max]="state.max" class="form-control"
[placeholder]="placeholder"
[title]="title"
[(ngModel)]="StringValue"
@ -131,7 +132,7 @@
<textarea
rows="5"
*ngSwitchCase="'SelectedThemeSettings'"
*ngSwitchCase="'ThemeConfig-Array'"
type="text"
class="form-control"
[title]="title"
@ -168,7 +169,7 @@
<app-settings-workflow
class="w-100"
*ngSwitchCase="'JobScheduleConfig'"
*ngSwitchCase="'JobScheduleConfig-Array'"
[(ngModel)]="state.value"
[id]="idName"
[name]="idName"
@ -252,7 +253,7 @@
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'MapLayers'">
<ng-container *ngSwitchCase="'MapLayers-Array'">
<div class="container">
<table class="table">
<thead>
@ -295,7 +296,7 @@
</div>
</ng-container>
<ng-container *ngSwitchCase="'MapPathGroupThemeConfig'">
<ng-container *ngSwitchCase="'MapPathGroupThemeConfig-Array'">
<div class="container">
<div class="row mt-1 mb-1 bg-body-tertiary" *ngFor="let val of state.value; index as i; last as isLast">
<div class="row mt-1 mb-1 bg-body-tertiary">
@ -357,7 +358,7 @@
</div>
</ng-container>
<ng-container *ngSwitchCase="'MapPathGroupConfig'">
<ng-container *ngSwitchCase="'MapPathGroupConfig-Array'">
<div class="container">
<div class="bg-body-tertiary row mt-1 mb-1" *ngFor="let arr of state.value; let i = index">
<div class="row mt-1 mb-1 me-0 ms-0 pe-0 ps-0">
@ -403,7 +404,7 @@
</div>
</ng-container>
<ng-container *ngSwitchCase="'NavigationLinkConfig'">
<ng-container *ngSwitchCase="'NavigationLinkConfig-Array'">
<div class="container">
<div class="row mt-1 mb-1 bg-body-tertiary" *ngFor="let link of state.value; let i = index">
<div class="col ps-0">
@ -468,7 +469,8 @@
</div>
</ng-container>
<ng-container *ngSwitchCase="'UserConfig'">
<ng-container *ngSwitchCase="'UserConfig-Array'">
<div class="container ps-0 pe-0">
<div class="row ms-0 me-0 mt-1 mb-1 bg-body-tertiary" *ngFor="let item of state.value; let i = index">

View File

@ -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 {

View File

@ -3,7 +3,7 @@
<div class="card-header">
<h5 class="d-inline-block mb-0">
<ng-icon [name]="icon"></ng-icon>
{{Name}}
{{ Name }}
</h5>
<div *ngIf="states.value.enabled !== undefined" class="float-end p-0">
@ -22,7 +22,7 @@
</div>
</div>
<div class="card-body">
<div [hidden]="!error" class="alert alert-danger" role="alert"><strong>Error: </strong>{{error}}</div>
<div [hidden]="!error" class="alert alert-danger" role="alert"><strong>Error: </strong>{{ error }}</div>
<ng-container *ngIf="states.value.enabled !== false">
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: states,topLevel:true,confPath:ConfigPath,skipJobs:true }"
@ -31,7 +31,7 @@
<div class="panel-info"
*ngIf="states.value.enabled === false">
{{Name}} <span i18n>config is not supported with these settings.</span>
{{ Name }} <span i18n>config is not supported with these settings.</span>
</div>
<div class="row mt-2">
<div class="col-auto" *ngIf="states.tags?.uiJob && !states.tags?.uiJob[0].description">
@ -72,46 +72,71 @@
let-confPath="confPath">
<div class="alert alert-secondary" role="alert"
*ngIf="rStates.description && settingsService.configStyle == ConfigStyle.full">
<ng-icon size="1.3em" name="ionInformationCircleOutline"></ng-icon> {{rStates.description}}
<ng-icon size="1.3em" name="ionInformationCircleOutline"></ng-icon>
{{ rStates.description }}
<a *ngIf="rStates.tags?.githubIssue"
[href]="'https://github.com/bpatrik/pigallery2/issues/'+rStates.tags?.githubIssue">
<ng-container i18n>See</ng-container>
#{{rStates.tags?.githubIssue}}.</a>
#{{ rStates.tags?.githubIssue }}.</a>
</div>
<ng-container *ngFor="let ck of getKeys(rStates)">
<ng-container *ngIf="!(rStates.value.__state[ck].shouldHide && rStates.value.__state[ck].shouldHide())">
<app-settings-entry
*ngIf="(ck!=='enabled' || !topLevel) && !isExpandableConfig(rStates.value.__state[ck])"
[name]="confPath+'_'+ck"
[ngModel]="rStates?.value.__state[ck]">
</app-settings-entry>
<ng-container *ngIf="isExpandableConfig(rStates.value.__state[ck])">
<div class="card mt-2 mb-2" *ngIf="topLevel && rStates?.value.__state[ck].tags?.uiIcon"
[id]="ConfigPath+'.'+ck">
<div class="card-body">
<h5 class="card-title">
<ng-icon [name]="rStates?.value.__state[ck].tags?.uiIcon"></ng-icon>
{{rStates?.value.__state[ck].tags?.name || ck}}
</h5>
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: rStates.value.__state[ck], confPath:confPath+'.'+ck }"
></ng-container>
<!-- is array -->
<ng-container *ngIf="rStates.value.__state[ck].isConfigArrayType && isExpandableArrayConfig(rStates.value.__state[ck])">
<div class="row">
<div class="col-auto">
<h5>{{ rStates?.value.__state[ck].tags?.name || ck }}</h5>
</div>
<div class="col mt-2">
<div class="row mt-2" *ngFor="let entry of rStates.value.__state[ck].value; let i = index">
<hr/>
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: {value: rStates.value.__state[ck].value[i]}, confPath:confPath+'.'+ck+'.'+i }"
></ng-container>
</div>
</div>
</div>
<ng-container *ngIf="!topLevel || !rStates?.value.__state[ck].tags?.uiIcon">
<div class="row mt-2">
<div class="col-auto">
<h5>{{rStates?.value.__state[ck].tags?.name || ck}}</h5>
</div>
<div class="col">
<hr/>
</ng-container>
<ng-container *ngIf="!rStates.value.__state[ck].isConfigArrayType || !isExpandableArrayConfig(rStates.value.__state[ck])">
<!-- simple entries or complex once's but with custom UI--->
<app-settings-entry
*ngIf="(ck!=='enabled' || !topLevel) && !isExpandableConfig(rStates.value.__state[ck])"
[name]="confPath+'_'+ck"
[ngModel]="rStates?.value.__state[ck]">
</app-settings-entry>
<!-- Config entries --->
<ng-container *ngIf="isExpandableConfig(rStates.value.__state[ck])">
<!-- Sub category with header and menu item -->
<div class="card mt-2 mb-2" *ngIf="topLevel && rStates?.value.__state[ck].tags?.uiIcon"
[id]="ConfigPath+'.'+ck">
<div class="card-body">
<h5 class="card-title">
<ng-icon [name]="rStates?.value.__state[ck].tags?.uiIcon"></ng-icon>
{{ rStates?.value.__state[ck].tags?.name || ck }}
</h5>
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: rStates.value.__state[ck], confPath:confPath+'.'+ck }"
></ng-container>
</div>
</div>
<div class="mt-2">
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: rStates.value.__state[ck], confPath:confPath+'.'+ck }"
></ng-container>
</div>
<!-- Sub category without header and menu item -->
<ng-container *ngIf="!topLevel || !rStates?.value.__state[ck].tags?.uiIcon">
<div class="row mt-2">
<div class="col-auto">
<h5>{{ rStates?.value.__state[ck].tags?.name || ck }}</h5>
</div>
<div class="col">
<hr/>
</div>
</div>
<div class="mt-2">
<ng-container
*ngTemplateOutlet="Recursion; context:{ rStates: rStates.value.__state[ck], confPath:confPath+'.'+ck }"
></ng-container>
</div>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
@ -125,12 +150,14 @@
></ng-container>
</div>
</ng-template>
<ng-template #JobTemplate let-uiJob="uiJob">
<div class="mb-2">
<ng-container *ngFor="let job of uiJob; let i = index">
<div class="alert alert-secondary" role="alert"
*ngIf="job.description && settingsService.configStyle == ConfigStyle.full">
<ng-icon size="1.3em" name="ionInformationCircleOutline"></ng-icon> {{job.description}}
<ng-icon size="1.3em" name="ionInformationCircleOutline"></ng-icon>
{{ job.description }}
</div>
<app-settings-job-button
*ngIf="!job.relevant || job.relevant(settingsService.settings | async)"
@ -144,6 +171,7 @@
</div>
</ng-template>
<ng-template #JobProcessTemplate let-uiJob="uiJob">
<div>
<ng-container *ngFor="let job of uiJob">

View File

@ -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<boolean> {
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;

View File

@ -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,

View File

@ -3,7 +3,8 @@
"width": 1920,
"height": 1080
},
"creationDate": 1706659327000,
"creationDate": 1706655727000,
"creationDateOffset": "+01:00",
"fileSize": 111432,
"positionData": {
"GPSData": {

View File

@ -9,7 +9,8 @@
"model": "Canon EOS 600D"
},
"caption": "Bambi Caption",
"creationDate": -11630935227000,
"creationDate": -11630942427000,
"creationDateOffset": "+02:00",
"faces": [
{
"box": {

View File

@ -7,7 +7,7 @@
"make": "NIKON",
"model": "E880"
},
"creationDate": -2211753600000,
"creationDate": 0,
"fileSize": 72850,
"size": {
"height": 768,

View File

@ -4,7 +4,8 @@
"width": 26,
"height": 26
},
"creationDate": 1707167247786,
"creationDate": 1599990007000,
"creationDateOffset": "+05:00",
"fileSize": 5758,
"keywords": [
],

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -24,5 +24,6 @@
"size": {
"height": 26,
"width": 26
}
},
"creationDate": 1544748139000
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -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"
]
}

View File

@ -7,7 +7,8 @@
"make": "samsung",
"model": "SM-G975F"
},
"creationDate": 1619181527000,
"creationDate": 1619174327000,
"creationDateOffset": "+02:00",
"fileSize": 4877,
"rating":3,
"size": {

View File

@ -7,7 +7,8 @@
"make": "samsung",
"model": "SM-G975F"
},
"creationDate": 1614703656000,
"creationDate": 1614700056000,
"creationDateOffset": "+01:00",
"fileSize": 4709,
"keywords": [
"Max",

View File

@ -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'));