1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2025-01-14 14:43:17 +08:00

Merge pull request #685 from bpatrik/top-pick-sending

Top pick sending per e-mail
This commit is contained in:
Patrik J. Braun 2023-08-01 11:47:49 +02:00 committed by GitHub
commit 065c57f897
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 707 additions and 65 deletions

47
package-lock.json generated
View File

@ -23,11 +23,12 @@
"image-size": "1.0.2",
"locale": "0.1.0",
"node-geocoder": "4.2.0",
"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.0",
"typeconfig": "2.1.2",
"typeorm": "0.3.12",
"xml2js": "0.4.23"
},
@ -75,6 +76,7 @@
"@types/leaflet.markercluster": "1.5.1",
"@types/node": "18.15.0",
"@types/node-geocoder": "4.2.0",
"@types/nodemailer": "6.4.9",
"@types/sharp": "0.31.1",
"@types/xml2js": "0.4.11",
"@typescript-eslint/eslint-plugin": "5.54.1",
@ -4891,6 +4893,15 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
"integrity": "sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -16598,6 +16609,14 @@
"integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==",
"dev": true
},
"node_modules/nodemailer": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz",
"integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/non-layered-tidy-tree-layout": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz",
@ -21670,9 +21689,9 @@
}
},
"node_modules/typeconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.1.0.tgz",
"integrity": "sha512-2Sn2lB8nG9lOvy2jY/4U0HCkqJqc7Fpf8uF5hXaB/+YVnjexX05bfOxUpxIB0fh+Qob7TrVkHxt/2R3aacj8Cw==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.1.2.tgz",
"integrity": "sha512-4Zw3q9LmJ3OeVFMxpvbRYpcrR5mG7TTZUr+bYPepXvWCuqLC4fXLD+cjS1hl8e1oSmum6Xfl4vheFx33VevlqA==",
"dependencies": {
"minimist": "1.2.8"
}
@ -26709,6 +26728,15 @@
}
}
},
"@types/nodemailer": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.9.tgz",
"integrity": "sha512-XYG8Gv+sHjaOtUpiuytahMy2mM3rectgroNbs6R3djZEKmPNiIJwe9KqOJBGzKKnNZNKvnuvmugBgpq3w/S0ig==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@ -35797,6 +35825,11 @@
"integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==",
"dev": true
},
"nodemailer": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz",
"integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA=="
},
"non-layered-tidy-tree-layout": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz",
@ -39736,9 +39769,9 @@
}
},
"typeconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.1.0.tgz",
"integrity": "sha512-2Sn2lB8nG9lOvy2jY/4U0HCkqJqc7Fpf8uF5hXaB/+YVnjexX05bfOxUpxIB0fh+Qob7TrVkHxt/2R3aacj8Cw==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/typeconfig/-/typeconfig-2.1.2.tgz",
"integrity": "sha512-4Zw3q9LmJ3OeVFMxpvbRYpcrR5mG7TTZUr+bYPepXvWCuqLC4fXLD+cjS1hl8e1oSmum6Xfl4vheFx33VevlqA==",
"requires": {
"minimist": "1.2.8"
}

View File

@ -46,13 +46,14 @@
"image-size": "1.0.2",
"locale": "0.1.0",
"node-geocoder": "4.2.0",
"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.0",
"xml2js": "0.4.23",
"typeorm": "0.3.12"
"typeconfig": "2.1.2",
"typeorm": "0.3.12",
"xml2js": "0.4.23"
},
"devDependencies": {
"@angular-builders/custom-webpack": "15.0.0",
@ -95,6 +96,7 @@
"@types/leaflet.markercluster": "1.5.1",
"@types/node": "18.15.0",
"@types/node-geocoder": "4.2.0",
"@types/nodemailer": "6.4.9",
"@types/sharp": "0.31.1",
"@types/xml2js": "0.4.11",
"@typescript-eslint/eslint-plugin": "5.54.1",

View File

@ -0,0 +1,5 @@
/**
* Keeps the environment context
* Only use it in the Config constructor
*/
export const ServerEnvironment: { sendMailAvailable?: boolean } = {};

View File

@ -3,9 +3,7 @@ import {promises as fsp} from 'fs';
import * as archiver from 'archiver';
import {NextFunction, Request, Response} from 'express';
import {ErrorCodes, ErrorDTO} from '../../common/entities/Error';
import {
ParentDirectoryDTO,
} from '../../common/entities/DirectoryDTO';
import {ParentDirectoryDTO,} from '../../common/entities/DirectoryDTO';
import {ObjectManagers} from '../model/ObjectManagers';
import {ContentWrapper} from '../../common/entities/ConentWrapper';
import {ProjectPath} from '../ProjectPath';
@ -14,13 +12,11 @@ import {UserDTOUtils} from '../../common/entities/UserDTO';
import {MediaDTO, MediaDTOUtils} from '../../common/entities/MediaDTO';
import {QueryParams} from '../../common/QueryParams';
import {VideoProcessing} from '../model/fileprocessing/VideoProcessing';
import {
SearchQueryDTO,
SearchQueryTypes,
} from '../../common/entities/SearchQueryDTO';
import {SearchQueryDTO, SearchQueryTypes,} from '../../common/entities/SearchQueryDTO';
import {LocationLookupException} from '../exceptions/LocationLookupException';
import {SupportedFormats} from '../../common/SupportedFormats';
import {ServerTime} from './ServerTimingMWs';
import {SortingMethods} from '../../common/entities/SortingMethods';
export class GalleryMWs {
@ServerTime('1.db', 'List Directory')
@ -325,16 +321,16 @@ export class GalleryMWs {
req.params['searchQueryDTO'] as string
);
const photo =
await ObjectManagers.getInstance().SearchManager.getRandomPhoto(query);
if (!photo) {
const photos =
await ObjectManagers.getInstance().SearchManager.getNMedia(query, [SortingMethods.random], 1, true);
if (!photos || photos.length !== 1) {
return next(new ErrorDTO(ErrorCodes.INPUT_ERROR, 'No photo found'));
}
req.params['mediaPath'] = path.join(
photo.directory.path,
photo.directory.name,
photo.name
photos[0].directory.path,
photos[0].directory.name,
photos[0].name
);
return next();
} catch (e) {

View File

@ -9,6 +9,7 @@ import {SharingDTO} from '../../common/entities/SharingDTO';
import {Utils} from '../../common/Utils';
import {LoggerRouter} from '../routes/LoggerRouter';
import {TAGS} from '../../common/config/public/ClientConfig';
import {ConfigDiagnostics} from '../model/diagnostics/ConfigDiagnostics';
const forcedDebug = process.env['NODE_ENV'] === 'debug';

View File

@ -27,7 +27,6 @@ export class SettingsMWs {
try {
let settings = req.body.settings; // Top level settings JSON
const settingsPath: string = req.body.settingsPath; // Name of the top level settings
const transformer = await Config.original();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore

View File

@ -30,11 +30,11 @@ import {
} from '../../../common/entities/SearchQueryDTO';
import {GalleryManager} from './GalleryManager';
import {ObjectManagers} from '../ObjectManagers';
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
import {DatabaseType} from '../../../common/config/private/PrivateConfig';
import {Utils} from '../../../common/Utils';
import {FileEntity} from './enitites/FileEntity';
import {SQL_COLLATE} from './enitites/EntityUtils';
import {SortingMethods} from '../../../common/entities/SortingMethods';
export class SearchManager {
private DIRECTORY_SELECT = [
@ -348,19 +348,62 @@ export class SearchManager {
return result;
}
public async getRandomPhoto(query: SearchQueryDTO): Promise<PhotoDTO> {
private static setSorting<T>(
query: SelectQueryBuilder<T>,
sortings: SortingMethods[]
): SelectQueryBuilder<T> {
if (!sortings || !Array.isArray(sortings)) {
return query;
}
if (sortings.includes(SortingMethods.random) && sortings.length > 1) {
throw new Error('Error during applying sorting: Can\' randomize and also sort the result. Bad input:' + sortings.map(s => SortingMethods[s]).join(', '));
}
for (const sort of sortings) {
switch (sort) {
case SortingMethods.descDate:
query.addOrderBy('media.metadata.creationDate', 'DESC');
break;
case SortingMethods.ascDate:
query.addOrderBy('media.metadata.creationDate', 'ASC');
break;
case SortingMethods.descRating:
query.addOrderBy('media.metadata.rating', 'DESC');
break;
case SortingMethods.ascRating:
query.addOrderBy('media.metadata.rating', 'ASC');
break;
case SortingMethods.descName:
query.addOrderBy('media.name', 'DESC');
break;
case SortingMethods.ascName:
query.addOrderBy('media.name', 'ASC');
break;
case SortingMethods.random:
if (Config.Database.type === DatabaseType.mysql) {
query.groupBy('RAND(), media.id');
} else {
query.groupBy('RANDOM()');
}
break;
}
}
return query;
}
public async getNMedia(query: SearchQueryDTO, sortings: SortingMethods[], take: number, photoOnly = false) {
const connection = await SQLConnection.getConnection();
const sqlQuery: SelectQueryBuilder<PhotoEntity> = connection
.getRepository(PhotoEntity)
.getRepository(photoOnly ? PhotoEntity : MediaEntity)
.createQueryBuilder('media')
.select(['media', ...this.DIRECTORY_SELECT])
.innerJoin('media.directory', 'directory')
.where(await this.prepareAndBuildWhereQuery(query));
SearchManager.setSorting(sqlQuery, sortings);
return sqlQuery.limit(take).getMany();
if (Config.Database.type === DatabaseType.mysql) {
return await sqlQuery.groupBy('RAND(), media.id').limit(1).getOne();
}
return await sqlQuery.groupBy('RANDOM()').limit(1).getOne();
}
public async getCount(query: SearchQueryDTO): Promise<number> {

View File

@ -26,6 +26,9 @@ import {
import {SearchQueryParser} from '../../../common/SearchQueryParser';
import {SearchQueryTypes, TextSearch,} from '../../../common/entities/SearchQueryDTO';
import {Utils} from '../../../common/Utils';
import {createTransport} from 'nodemailer';
import {EmailMessagingType, MessagingConfig} from '../../../common/config/private/MessagingConfig';
import {ServerEnvironment} from '../../Environment';
const LOG_TAG = '[ConfigDiagnostics]';
@ -79,6 +82,14 @@ export class ConfigDiagnostics {
}
}
private static async testEmailMessagingConfig(Messaging: MessagingConfig, config: PrivateConfigClass): Promise<void> {
Logger.debug(LOG_TAG, 'Testing EmailMessaging config');
if (Messaging.Email.type === EmailMessagingType.sendmail && ServerEnvironment.sendMailAvailable === false) {
throw new Error('sendmail e-mail sending method is not supported as the sendmail application cannot be found in the OS.');
}
}
static testVideoConfig(videoConfig: ServerVideoConfig,
config: PrivateConfigClass): Promise<void> {
Logger.debug(LOG_TAG, 'Testing video config with ffmpeg test');
@ -285,15 +296,49 @@ export class ConfigDiagnostics {
await ConfigDiagnostics.testSharingConfig(config.Sharing, config);
await ConfigDiagnostics.testRandomPhotoConfig(config.Sharing, config);
await ConfigDiagnostics.testMapConfig(config.Map);
await ConfigDiagnostics.testEmailMessagingConfig(config.Messaging, config);
}
static async checkAndSetEnvironment(): Promise<void> {
Logger.debug(LOG_TAG, 'Checking sendmail availability');
const transporter = createTransport({
sendmail: true,
});
try {
ServerEnvironment.sendMailAvailable = await transporter.verify();
} catch (e) {
ServerEnvironment.sendMailAvailable = false;
}
Config.Environment.sendMailAvailable = ServerEnvironment.sendMailAvailable;
if (!ServerEnvironment.sendMailAvailable) {
Config.Messaging.Email.type = EmailMessagingType.SMTP;
Logger.info(LOG_TAG, 'Sendmail is not available on the OS. You will need to use an SMTP server if you wish the app to send mails.');
}
}
static async runDiagnostics(): Promise<void> {
if (process.env['NODE_ENV'] === 'debug') {
NotificationManager.warning('You are running the application with NODE_ENV=debug. This exposes a lot of debug information that can be a security vulnerability. Set NODE_ENV=production, when you finished debugging.');
}
try {
await ConfigDiagnostics.checkAndSetEnvironment();
} catch (ex) {
const err: Error = ex;
NotificationManager.error(
'Error during checking environment',
err.toString()
);
Logger.error(
LOG_TAG,
'Error during checking environment',
err.toString()
);
process.exit(1);
}
try {
await ConfigDiagnostics.testDatabase(Config.Database);
} catch (ex) {
@ -525,5 +570,23 @@ export class ConfigDiagnostics {
);
Config.Map.mapProvider = MapProviders.OpenStreetMap;
}
try {
await ConfigDiagnostics.testEmailMessagingConfig(Config.Messaging, Config);
} catch (ex) {
const err: Error = ex;
NotificationManager.warning(
'Setting to SMTP method.',
err.toString()
);
Logger.warn(
LOG_TAG,
'Setting to SMTP method.',
err.toString()
);
Config.Messaging.Email.type = EmailMessagingType.SMTP;
}
}
}

View File

@ -10,6 +10,7 @@ import {PreviewRestJob} from './jobs/PreviewResetJob';
import {GPXCompressionJob} from './jobs/GPXCompressionJob';
import {AlbumRestJob} from './jobs/AlbumResetJob';
import {GPXCompressionResetJob} from './jobs/GPXCompressionResetJob';
import {TopPickSendJob} from './jobs/TopPickSendJob';
export class JobRepository {
private static instance: JobRepository = null;
@ -45,3 +46,4 @@ JobRepository.Instance.register(new GPXCompressionJob());
JobRepository.Instance.register(new TempFolderCleaningJob());
JobRepository.Instance.register(new AlbumRestJob());
JobRepository.Instance.register(new GPXCompressionResetJob());
JobRepository.Instance.register(new TopPickSendJob());

View File

@ -154,6 +154,7 @@ export abstract class Job<T extends Record<string, any> = Record<string, any>> i
this.run();
} catch (e) {
Logger.error(LOG_TAG, e);
this.Progress.State = JobProgressStates.failed;
}
});
}

View File

@ -1,11 +1,6 @@
import { ObjectManagers } from '../../ObjectManagers';
import {
ConfigTemplateEntry,
DefaultsJobs,
} from '../../../../common/entities/job/JobDTO';
import { Job } from './Job';
import { Config } from '../../../../common/config/private/Config';
import { DatabaseType } from '../../../../common/config/private/PrivateConfig';
import {ObjectManagers} from '../../ObjectManagers';
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
export class PreviewFillingJob extends Job {
public readonly Name = DefaultsJobs[DefaultsJobs['Preview Filling']];
@ -18,7 +13,7 @@ export class PreviewFillingJob extends Job {
}
protected async init(): Promise<void> {
// abstract function
this.status = 'Persons';
}
protected async step(): Promise<boolean> {

View File

@ -0,0 +1,116 @@
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {backendTexts} from '../../../../common/BackendTexts';
import {SortingMethods} from '../../../../common/entities/SortingMethods';
import {DatePatternFrequency, DatePatternSearch, SearchQueryDTO, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO';
import {ObjectManagers} from '../../ObjectManagers';
import {PhotoEntity} from '../../database/enitites/PhotoEntity';
import {EmailMediaMessenger} from '../../mediamessengers/EmailMediaMessenger';
export class TopPickSendJob extends Job<{
searchQuery: SearchQueryDTO,
sortBy: SortingMethods[],
pickAmount: number,
emailTo: string,
emailFrom: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']];
public readonly Supported: boolean = true;
public readonly ConfigTemplate: ConfigTemplateEntry[] = [
{
id: 'searchQuery',
type: 'SearchQuery',
name: backendTexts.searchQuery.name,
description: backendTexts.searchQuery.description,
defaultValue: {
type: SearchQueryTypes.date_pattern,
daysLength: 7,
frequency: DatePatternFrequency.every_year
} as DatePatternSearch,
}, {
id: 'sortby',
type: 'sort-array',
name: backendTexts.sortBy.name,
description: backendTexts.sortBy.description,
defaultValue: [SortingMethods.descRating],
}, {
id: 'pickAmount',
type: 'number',
name: backendTexts.pickAmount.name,
description: backendTexts.pickAmount.description,
defaultValue: 5,
}, {
id: 'emailTo',
type: 'email',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: '',
}, {
id: 'emailFrom',
type: 'email',
name: backendTexts.emailFrom.name,
description: backendTexts.emailFrom.description,
defaultValue: 'norelpy@pigallery2.com',
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
},
];
private status: 'Listing' | 'Sending' = 'Listing';
private mediaList: PhotoEntity[] = [];
protected async init(): Promise<void> {
this.status = 'Listing';
this.mediaList = [];
this.Progress.Left = 2;
}
protected async step(): Promise<boolean> {
switch (this.status) {
case 'Listing':
if (!await this.stepListing()) {
this.status = 'Sending';
}
return true;
case 'Sending':
await this.stepSending();
}
return false;
}
private async stepListing(): Promise<boolean> {
this.Progress.log('Collecting Photos and videos to Send');
this.Progress.Processed++;
this.mediaList = await ObjectManagers.getInstance().SearchManager.getNMedia(this.config.searchQuery, this.config.sortBy, this.config.pickAmount);
// console.log(this.mediaList);
return false;
}
private async stepSending(): Promise<boolean> {
this.Progress.log('Sending emails');
const messenger = new EmailMediaMessenger();
await messenger.sendMedia({
from: this.config.emailFrom,
to: this.config.emailTo,
subject: this.config.emailSubject,
text: this.config.emailText
}, this.mediaList);
this.Progress.Processed++;
return false;
}
}

View File

@ -0,0 +1,94 @@
import {createTransport, Transporter} from 'nodemailer';
import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO';
import {Config} from '../../../common/config/private/Config';
import {EmailMessagingType} from '../../../common/config/private/MessagingConfig';
import {PhotoProcessing} from '../fileprocessing/PhotoProcessing';
import {ThumbnailSourceType} from '../threading/PhotoWorker';
import {ProjectPath} from '../../ProjectPath';
import * as path from 'path';
import {PhotoMetadata} from '../../../common/entities/PhotoDTO';
import {Utils} from '../../../common/Utils';
export class EmailMediaMessenger {
transporter: Transporter;
constructor() {
if (Config.Messaging.Email.type === EmailMessagingType.sendmail) {
this.transporter = createTransport({
sendmail: true
});
} else {
this.transporter = createTransport({
host: Config.Messaging.Email.smtp.host,
port: Config.Messaging.Email.smtp.port,
secure: Config.Messaging.Email.smtp.secure,
requireTLS: Config.Messaging.Email.smtp.requireTLS,
auth: {
user: Config.Messaging.Email.smtp.user,
pass: Config.Messaging.Email.smtp.password
}
});
}
}
private async getThumbnail(m: MediaDTO) {
return await PhotoProcessing.generateThumbnail(
path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name),
Config.Media.Thumbnail.thumbnailSizes[0],
MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video,
false
);
}
public async sendMedia(mailSettings: {
from: string,
to: string,
subject: string,
text: string
}, media: MediaDTO[]) {
const attachments = [];
const htmlStart = '<h1 style="text-align: center; margin-bottom: 2em">' + Config.Server.applicationTitle + '</h1>\n' +
'<h3>' + mailSettings.text + '</h3>\n' +
'<table style="margin-left: auto; margin-right: auto;">\n' +
' <tbody>\n';
const htmlEnd = ' </tr>\n' +
' </tbody>\n' +
'</table>';
let htmlMiddle = '';
for (let i = 0; i < media.length; ++i) {
const thPath = await this.getThumbnail(media[i]);
const linkUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', path.join(media[i].directory.path, media[i].directory.name));
const location = (media[0].metadata as PhotoMetadata).positionData?.country ?
(media[0].metadata as PhotoMetadata).positionData?.country :
((media[0].metadata as PhotoMetadata).positionData?.city ?
(media[0].metadata as PhotoMetadata).positionData?.city : '');
const caption = (new Date(media[0].metadata.creationDate)).getFullYear() + (location ? ', ' + location : '');
attachments.push({
filename: media[i].name,
path: thPath,
cid: 'img' + i
});
if (i % 2 == 0) {
htmlMiddle += '<tr>';
}
htmlMiddle += '<td>\n' +
' <a style="display: block;text-align: center;" href="' + linkUrl + '"><img alt="' + media[i].name + '" style="max-width: 200px; height: 150px" src="cid:img' + i + '"/></a>\n' +
caption +
' </td>\n';
if (i % 2 == 1 || i === media.length - 1) {
htmlMiddle += '</tr>';
}
}
return await this.transporter.sendMail({
from: mailSettings.from,
to: mailSettings.to,
subject: mailSettings.subject,
html: htmlStart + htmlMiddle + htmlEnd,
attachments: attachments
});
}
}

View File

@ -1,6 +1,14 @@
export type backendText = number;
export const backendTexts = {
indexedFilesOnly: { name: 10, description: 12 },
sizeToGenerate: { name: 20, description: 22 },
indexChangesOnly: { name: 30, description: 32 },
indexedFilesOnly: {name: 10, description: 12},
sizeToGenerate: {name: 20, description: 22},
indexChangesOnly: {name: 30, description: 32},
searchQuery: {name: 40, description: 42},
sortBy: {name: 50, description: 52},
pickAmount: {name: 60, description: 62},
emailTo: {name: 70, description: 72},
emailFrom: {name: 80, description: 82},
emailSubject: {name: 90, description: 92},
emailText: {name: 100, description: 102}
};

View File

@ -6,6 +6,8 @@ import {ConfigClass, ConfigClassBuilder} from 'typeconfig/node';
import {IConfigClass} from 'typeconfig/common';
import {PasswordHelper} from '../../../backend/model/PasswordHelper';
import {TAGS} from '../public/ClientConfig';
import {ServerEnvironment} from '../../../backend/Environment';
import {EmailMessagingType} from './MessagingConfig';
declare const process: any;
@ -16,7 +18,8 @@ const isTesting = ['afterEach', 'after', 'beforeEach', 'before', 'describe', 'it
.every((fn) => (global as any)[fn] instanceof Function);
@ConfigClass<IConfigClass<TAGS> & ServerConfig>({
configPath: path.join(__dirname, !isTesting ? './../../../../config.json' : './../../../../test/backend/assets/config.json'),
configPath: path.join(__dirname, !isTesting ? './../../../../config.json' : './../../../../test/backend/tmp/config.json'),
crateConfigPathIfNotExists: isTesting,
saveIfNotExist: true,
attachDescription: true,
enumsAsString: true,
@ -79,6 +82,12 @@ export class PrivateConfigClass extends ServerConfig {
require('../../../../package.json').buildCommitHash;
this.Environment.upTime = upTime;
this.Environment.isDocker = !!process.env.PI_DOCKER;
if (typeof ServerEnvironment.sendMailAvailable !== 'undefined') {
this.Environment.sendMailAvailable = ServerEnvironment.sendMailAvailable;
if (!this.Environment.sendMailAvailable) { //onNewValue is not yet available as a callback
this.Messaging.Email.type = EmailMessagingType.SMTP;
}
}
}
async original(): Promise<PrivateConfigClass & IConfigClass> {

View File

@ -0,0 +1,115 @@
/* eslint-disable @typescript-eslint/no-inferrable-types */
import {SubConfigClass} from '../../../../node_modules/typeconfig/src/decorators/class/SubConfigClass';
import {ConfigPriority, TAGS} from '../public/ClientConfig';
import {ConfigProperty} from '../../../../node_modules/typeconfig/src/decorators/property/ConfigPropoerty';
import {ServerConfig} from './PrivateConfig';
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;
}
export enum EmailMessagingType {
sendmail = 1,
SMTP = 2,
}
@SubConfigClass<TAGS>({softReadonly: true})
export class EmailSMTPMessagingConfig {
@ConfigProperty({
tags: {
name: $localize`Host`,
priority: ConfigPriority.advanced,
hint: 'smtp.example.com'
},
description: $localize`SMTP host server`
})
host: string = '';
@ConfigProperty({
tags: {
name: $localize`Port`,
priority: ConfigPriority.advanced,
},
description: $localize`SMTP server's port`
})
port: number = 587;
@ConfigProperty({
tags: {
name: $localize`isSecure`,
priority: ConfigPriority.advanced,
},
description: $localize`Is the connection secure. See https://nodemailer.com/smtp/#tls-options for more details`
})
secure: boolean = false;
@ConfigProperty({
tags: {
name: $localize`TLS required`,
priority: ConfigPriority.advanced,
},
description: $localize`if this is true and secure is false then Nodemailer (used library in the background) tries to use STARTTLS. See https://nodemailer.com/smtp/#tls-options for more details`
})
requireTLS: boolean = true;
@ConfigProperty({
tags: {
name: $localize`User`,
priority: ConfigPriority.advanced,
},
description: $localize`User to connect to the SMTP server.`
})
user: string = '';
@ConfigProperty({
tags: {
name: $localize`Password`,
priority: ConfigPriority.advanced,
},
type: 'password',
description: $localize`Password to connect to the SMTP server.`
})
password: string = '';
}
@SubConfigClass<TAGS>({softReadonly: true})
export class EmailMessagingConfig {
@ConfigProperty<EmailMessagingType, EmailMessagingConfig>({
type: EmailMessagingType,
tags:
{
name: $localize`Sending method`,
priority: ConfigPriority.advanced,
uiDisabled: (sc: EmailMessagingConfig, c: ServerConfig) => !c.Environment.sendMailAvailable
} as TAGS,
description: $localize`Sendmail uses the built in unix binary if available. STMP connects to any STMP server of your choice.`
})
type: EmailMessagingType = EmailMessagingType.sendmail;
@ConfigProperty({
tags:
{
name: $localize`SMTP`,
relevant: (c: any) => c.type === EmailMessagingType.SMTP,
}
})
smtp?: EmailSMTPMessagingConfig = new EmailSMTPMessagingConfig();
}
@SubConfigClass<TAGS>({softReadonly: true})
export class MessagingConfig {
@ConfigProperty({
tags:
{
name: $localize`Email`,
},
description: $localize`The app uses Nodemailer in the background for sending e-mails. Refer to https://nodemailer.com/usage/ if some options are not clear.`
})
Email: EmailMessagingConfig = new EmailMessagingConfig();
}

View File

@ -30,6 +30,7 @@ import {DefaultsJobs} from '../../entities/job/JobDTO';
import {SearchQueryDTO, SearchQueryTypes, TextSearch,} from '../../entities/SearchQueryDTO';
import {SortingMethods} from '../../entities/SortingMethods';
import {UserRoles} from '../../entities/UserDTO';
import {EmailMessagingType, MessagingConfig} from './MessagingConfig';
declare let $localize: (s: TemplateStringsArray) => string;
@ -395,7 +396,7 @@ export class ServerMetaFileConfig extends ClientMetaFileConfig {
uiJob: [{
job: DefaultsJobs[DefaultsJobs['GPX Compression']],
relevant: (c) => c.MetaFile.GPXCompressing.enabled
},{
}, {
job: DefaultsJobs[DefaultsJobs['Delete Compressed GPX']],
relevant: (c) => c.MetaFile.GPXCompressing.enabled
}]
@ -903,6 +904,7 @@ export class ServerPreviewConfig {
@SubConfigClass({softReadonly: true})
export class ServerMediaConfig extends ClientMediaConfig {
@ConfigProperty({
tags: {
name: $localize`Images folder`,
priority: ConfigPriority.basic,
@ -1049,8 +1051,19 @@ export class ServerEnvironmentConfig {
buildCommitHash: string | undefined;
@ConfigProperty({volatile: true})
isDocker: boolean | undefined;
@ConfigProperty<boolean, ServerConfig, TAGS>({
volatile: true,
onNewValue: (value, config) => {
if (value === false) {
config.Messaging.Email.type = EmailMessagingType.SMTP;
}
},
description: 'App updates on start-up if sendmail binary is available'
})
sendMailAvailable: boolean | undefined;
}
@SubConfigClass<TAGS>({softReadonly: true})
export class ServerConfig extends ClientConfig {
@ -1144,6 +1157,16 @@ export class ServerConfig extends ClientConfig {
})
Duplicates: ServerDuplicatesConfig = new ServerDuplicatesConfig();
@ConfigProperty({
tags: {
name: $localize`Messaging`,
uiIcon: 'chat',
githubIssue: 683
} as TAGS,
description: $localize`The App can send messages (like photos on the same day a year ago. aka: "Top Pick"). Here you can configure the delivery method.`
})
Messaging: MessagingConfig = new MessagingConfig();
@ConfigProperty({
tags: {
name: $localize`Jobs`,

View File

@ -1,6 +1,6 @@
import {backendText} from '../../BackendTexts';
export type fieldType = 'string' | 'number' | 'boolean' | 'number-array';
export type fieldType = 'string' | 'email' | 'number' | 'boolean' | 'number-array' | 'SearchQuery' | 'sort-array';
export enum DefaultsJobs {
Indexing = 1,
@ -13,7 +13,8 @@ export enum DefaultsJobs {
'Preview Reset' = 8,
'GPX Compression' = 9,
'Album Reset' = 10,
'Delete Compressed GPX' = 11
'Delete Compressed GPX' = 11,
'Top Pick Sending' = 12
}
export interface ConfigTemplateEntry {

View File

@ -4,6 +4,7 @@ export enum JobProgressStates {
interrupted = 3,
canceled = 4,
finished = 5,
failed = 6,
}
export interface JobProgressLogDTO {

View File

@ -1,6 +1,6 @@
import { inject, TestBed } from '@angular/core/testing';
import { BackendtextService } from './backendtext.service';
import { backendTexts } from '../../../common/BackendTexts';
import {inject, TestBed} from '@angular/core/testing';
import {BackendtextService} from './backendtext.service';
import {backendTexts} from '../../../common/BackendTexts';
describe('BackendTextService', () => {
beforeEach(() => {
@ -9,7 +9,7 @@ describe('BackendTextService', () => {
});
});
it('should call UserDTO service login', inject(
it('should have valid text for all keys', inject(
[BackendtextService],
(backendTextService: BackendtextService) => {
const getTexts = (obj: any) => {
@ -18,7 +18,7 @@ describe('BackendTextService', () => {
getTexts(obj[key]);
continue;
}
expect(backendTextService.get(obj[key])).not.toBe(null);
expect(backendTextService.get(obj[key])).not.toEqual(null, 'Error for key: ' + obj[key] +', ' + key);
}
};
getTexts(backendTexts);

View File

@ -19,6 +19,34 @@ export class BackendtextService {
return $localize`Index changes only`;
case backendTexts.indexChangesOnly.description:
return $localize`Only indexes a folder if it got changed.`;
case backendTexts.searchQuery.name:
return $localize`Search query`;
case backendTexts.searchQuery.description:
return $localize`Search query to list photos and videos.`;
case backendTexts.sortBy.name:
return $localize`Sorting`;
case backendTexts.sortBy.description:
return $localize`Sorts the photos and videos by this.`;
case backendTexts.pickAmount.name:
return $localize`Pick`;
case backendTexts.pickAmount.description:
return $localize`Number of photos and videos to pick.`;
case backendTexts.emailTo.name:
return $localize`E-mail to`;
case backendTexts.emailTo.description:
return $localize`E-mail address of the recipient.`;
case backendTexts.emailFrom.name:
return $localize`E-mail From`;
case backendTexts.emailFrom.description:
return $localize`E-mail sender address.`;
case backendTexts.emailSubject.name:
return $localize`Subject`;
case backendTexts.emailSubject.description:
return $localize`E-mail subject.`;
case backendTexts.emailText.name:
return $localize`Message`;
case backendTexts.emailText.description:
return $localize`E-mail text.`;
default:
return null;
}

View File

@ -71,6 +71,10 @@
let-confPath="confPath">
<div class="alert alert-secondary" role="alert" *ngIf="rStates.description">
{{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>
</div>
<ng-container *ngFor="let ck of getKeys(rStates)">
<ng-container *ngIf="!(rStates.value.__state[ck].shouldHide && rStates.value.__state[ck].shouldHide())">

View File

@ -115,6 +115,8 @@ export class JobProgressComponent implements OnDestroy, OnChanges {
return $localize`interrupted`;
case JobProgressStates.finished:
return $localize`finished`;
case JobProgressStates.failed:
return $localize`failed`;
default:
return 'unknown state';
}

View File

@ -0,0 +1,3 @@
app-gallery-search-field {
width: 100%;
}

View File

@ -1,6 +1,7 @@
<div [hidden]="!error" class="alert alert-danger" role="alert"><strong>Error: </strong>{{error}}</div>
<div *ngFor="let schedule of sortedSchedules() as sortedSchedules; let i= index">
<div class="card bg-body-tertiary mt-2 mb-2 no-changed-settings {{shouldIdent(schedule,sortedSchedules[i-1])? 'ms-4' : ''}}">
<div
class="card bg-body-tertiary mt-2 mb-2 no-changed-settings {{shouldIdent(schedule,sortedSchedules[i-1])? 'ms-4' : ''}}">
<div class="card-header">
<div class="d-flex justify-content-between">
<div (click)="showDetails[schedule.name]=!showDetails[schedule.name]">
@ -175,6 +176,14 @@
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'email'">
<input type="email" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
placeholder="adress@domain.com"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]" required>
</ng-container>
<ng-container *ngSwitchCase="'number'">
<input type="number" class="form-control" [name]="configEntry.id+'_'+i"
[id]="configEntry.id+'_'+i"
@ -189,6 +198,54 @@
(ngModelChange)="setNumberArray(schedule.config,configEntry.id,$event); onChange($event);"
[ngModel]="getNumberArray($any(schedule.config),configEntry.id)" required>
</ng-container>
<app-gallery-search-field
*ngSwitchCase="'SearchQuery'"
[(ngModel)]="schedule.config[configEntry.id]"
[id]="configEntry.id+'_'+i"
[name]="configEntry.id+'_'+i"
(change)="onChange($event)"
placeholder="Search Query">
</app-gallery-search-field>
<ng-container *ngSwitchCase="'sort-array'">
<ng-container *ngFor="let _ of AsSortArray(schedule.config[configEntry.id]); let j=index">
<div class="row col-12 mt-1 m-0 p-0">
<div class="col p-0">
<select
[id]="configEntry.id+'_'+i+'_'+j"
[name]="configEntry.id+'_'+i+'_'+j"
(ngModelChange)="onChange($event)"
class="form-select" [(ngModel)]="AsSortArray(schedule.config[configEntry.id])[j]">
<option *ngFor="let opt of SortingMethods" [ngValue]="opt.key">{{opt.value}}
</option>
</select>
</div>
<ng-container>
<div class="col-auto pe-0">
<button class="btn btn-secondary float-end"
[id]="'list_btn_'+configEntry.id+'_'+i+'_'+j"
[name]="'list_btn_'+configEntry.id+'_'+i+'_'+j"
(click)="removeSorting(schedule.config[configEntry.id],j)"><span
class="oi oi-trash"></span>
</button>
</div>
</ng-container>
</div>
</ng-container>
<ng-container>
<div class="col-12 p-0">
<button class="btn btn-primary mt-1 float-end"
[id]="'btn_add_'+configEntry.id+'_'+i"
[name]="'btn_add_'+configEntry.id+'_'+i"
(click)="AddNewSorting(schedule.config[configEntry.id])" i18n>+ Add
</button>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
<small class="form-text text-muted">

View File

@ -19,6 +19,8 @@ import {
ScheduledJobTriggerConfig
} from '../../../../../common/config/private/PrivateConfig';
import {ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator} from '@angular/forms';
import {enumToTranslatedArray} from '../../EnumTranslations';
import {SortingMethods} from '../../../../../common/entities/SortingMethods';
@Component({
selector: 'app-settings-workflow',
@ -61,6 +63,9 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
allowParallelRun: false,
};
SortingMethods = enumToTranslatedArray(SortingMethods);
error: string;
constructor(
@ -278,4 +283,17 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
this.onTouched = fn;
}
AsSortArray(configElement: string | number | string[] | number[]): SortingMethods[] {
return configElement as SortingMethods[];
}
removeSorting(configElement: string | number | string[] | number[], j: number): void {
(configElement as SortingMethods[]).splice(j);
}
AddNewSorting(configElement: string | number | string[] | number[]): void {
(configElement as SortingMethods[]).push(SortingMethods.ascDate)
}
}

View File

@ -1,12 +1,12 @@
import * as path from 'path';
import * as fs from 'fs';
import {Config} from '../../../../../src/common/config/private/Config';
import {SQLConnection} from '../../../../../src/backend/model/database/SQLConnection';
import {Server} from '../../../../../src/backend/server';
import {DatabaseType, ServerConfig} from '../../../../../src/common/config/private/PrivateConfig';
import {ProjectPath} from '../../../../../src/backend/ProjectPath';
import {TAGS} from '../../../../../src/common/config/public/ClientConfig';
import {ObjectManagers} from '../../../../../src/backend/model/ObjectManagers';
import {UserRoles} from '../../../../../src/common/entities/UserDTO';
process.env.NODE_ENV = 'test';
const chai: any = require('chai');
@ -34,9 +34,8 @@ describe('SettingsRouter', () => {
describe('/GET settings', () => {
it('it should GET the settings', async () => {
Config.Users.authenticationRequired = false;
Config.Users.unAuthenticatedUserRole = UserRoles.Admin;
const originalSettings = await Config.original();
// originalSettings.Server.sessionSecret = null;
// originalSettings.Users.enforcedUsers = null;
const srv = new Server();
await srv.onStarted.wait();
const result = await chai.request(srv.App)
@ -46,7 +45,9 @@ describe('SettingsRouter', () => {
result.body.should.be.a('object');
should.equal(result.body.error, null);
(result.body.result as ServerConfig).Environment.upTime = null;
(result.body.result as ServerConfig).Environment.sendMailAvailable = null;
originalSettings.Environment.upTime = null;
originalSettings.Environment.sendMailAvailable = null;
result.body.result.should.deep.equal(JSON.parse(JSON.stringify(originalSettings.toJSON({
attachState: true,
attachVolatile: true,

View File

@ -6,6 +6,11 @@ import {SettingsMWs} from '../../../../../src/backend/middlewares/admin/Settings
import {ServerUserConfig} from '../../../../../src/common/config/private/PrivateConfig';
import {Config} from '../../../../../src/common/config/private/Config';
import {UserRoles} from '../../../../../src/common/entities/UserDTO';
import {ConfigClassBuilder} from '../../../../../node_modules/typeconfig/node';
import {ServerEnvironment} from '../../../../../src/backend/Environment';
import {EmailMessagingType} from '../../../../../src/common/config/private/MessagingConfig';
import * as fs from 'fs';
import * as path from 'path';
declare const describe: any;
@ -14,11 +19,16 @@ declare const beforeEach: any;
describe('Settings middleware', () => {
beforeEach(() => {
ObjectManagers.reset();
const tempDir = path.join(__dirname, '../../../tmp');
beforeEach(async () => {
await ObjectManagers.reset();
await fs.promises.rm(tempDir, {recursive: true, force: true});
});
it('should save empty enforced users settings', (done: (err?: any) => void) => {
ServerEnvironment.sendMailAvailable = false;
Config.Environment.sendMailAvailable = false;
Config.Messaging.Email.type = EmailMessagingType.SMTP;
const req: any = {
session: {},
sessionOptions: {},
@ -26,15 +36,17 @@ describe('Settings middleware', () => {
params: {},
body: {
settingsPath: 'Users',
settings: new ServerUserConfig()
settings: ConfigClassBuilder.attachPrivateInterface(new ServerUserConfig()).toJSON()
}
};
req.body.settings.enforcedUsers = [];
const next: any = (err: ErrorDTO) => {
try {
expect(err).to.be.undefined;
expect(Config.Users.enforcedUsers.length).to.be.equal(0);
done();
} catch (err) {
console.error(err);
done(err);
}
};
@ -43,6 +55,10 @@ describe('Settings middleware', () => {
});
it('should save enforced users settings', (done: (err?: any) => void) => {
ServerEnvironment.sendMailAvailable = false;
Config.Environment.sendMailAvailable = false;
Config.Messaging.Email.type = EmailMessagingType.SMTP;
const req: any = {
session: {},
sessionOptions: {},
@ -66,11 +82,17 @@ describe('Settings middleware', () => {
expect(Config.Users.enforcedUsers[0].name).to.be.equal('Apple');
expect(Config.Users.enforcedUsers.length).to.be.equal(1);
Config.original().then((cfg) => {
expect(cfg.Users.enforcedUsers.length).to.be.equal(1);
expect(cfg.Users.enforcedUsers[0].name).to.be.equal('Apple');
done();
try {
expect(cfg.Users.enforcedUsers.length).to.be.equal(1);
expect(cfg.Users.enforcedUsers[0].name).to.be.equal('Apple');
done();
} catch (err) {
console.error(err);
done(err);
}
}).catch(done);
} catch (err) {
console.error(err);
done(err);
}
};

View File

@ -711,7 +711,6 @@ describe('IndexingManager', (sqlHelper: DBTestHelper) => {
const albums = await am.getAlbums();
expect(albums[0].preview).to.be.an('object');
delete albums[0].preview;
console.log(albums);
expect(albums).to.be.deep.equal([
{
id: 1,

View File

@ -35,6 +35,7 @@ import {AutoCompleteItem} from '../../../../../src/common/entities/AutoCompleteI
import {Config} from '../../../../../src/common/config/private/Config';
import {SearchQueryParser} from '../../../../../src/common/SearchQueryParser';
import {FileDTO} from '../../../../../src/common/entities/FileDTO';
import {SortingMethods} from '../../../../../src/common/entities/SortingMethods';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const deepEqualInAnyOrder = require('deep-equal-in-any-order');
@ -1405,14 +1406,14 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
} as TextSearch;
// eslint-disable-next-line
expect(await sm.getRandomPhoto(query)).to.not.exist;
expect(await sm.getNMedia(query, [SortingMethods.random], 1, true)).to.deep.equalInAnyOrder([]);
query = ({
text: 'wookiees',
matchType: TextSearchQueryMatchTypes.exact_match,
type: SearchQueryTypes.keyword
} as TextSearch);
expect(Utils.clone(await sm.getRandomPhoto(query))).to.deep.equalInAnyOrder(searchifyMedia(pFaceLess));
expect(Utils.clone(await sm.getNMedia(query, [SortingMethods.random], 1, true))).to.deep.equalInAnyOrder([searchifyMedia(pFaceLess)]);
});
});