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

Refactoring messenger to prepare extension support #753

This commit is contained in:
Patrik J. Braun 2023-11-18 16:26:42 +01:00
parent 7208a3b4fe
commit 50b8f7a81d
19 changed files with 370 additions and 181 deletions

View File

@ -2,6 +2,7 @@ import {NextFunction, Request, Response} from 'express';
import {ErrorCodes, ErrorDTO} from '../../../common/entities/Error';
import {ObjectManagers} from '../../model/ObjectManagers';
import {StatisticDTO} from '../../../common/entities/settings/StatisticDTO';
import {MessengerRepository} from '../../model/messenger/MessengerRepository';
export class AdminMWs {
public static async loadStatistic(
@ -80,7 +81,7 @@ export class AdminMWs {
): Promise<void> {
try {
const id = req.params['id'];
const JobConfig: unknown = req.body.config;
const JobConfig: Record<string, unknown> = req.body.config;
const soloRun: boolean = req.body.soloRun;
const allowParallelRun: boolean = req.body.allowParallelRun;
await ObjectManagers.getInstance().JobManager.run(
@ -137,6 +138,35 @@ export class AdminMWs {
}
}
public static getAvailableMessengers(
req: Request,
res: Response,
next: NextFunction
): void {
try {
req.resultPipe = MessengerRepository.Instance.getAll().map(msgr => msgr.Name);
return next();
} catch (err) {
if (err instanceof Error) {
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Messenger error: ' + err.toString(),
err
)
);
}
return next(
new ErrorDTO(
ErrorCodes.JOB_ERROR,
'Messenger error: ' + JSON.stringify(err, null, ' '),
err
)
);
}
}
public static getAvailableJobs(
req: Request,
res: Response,

View File

@ -50,7 +50,7 @@ export class JobManager implements IJobListener, IObjectManager {
return prg;
}
public async run<T>(
public async run<T extends Record<string, unknown>>(
jobName: string,
config: T,
soloRun: boolean,
@ -86,7 +86,7 @@ export class JobManager implements IJobListener, IObjectManager {
};
onJobFinished = async (
job: IJob<unknown>,
job: IJob,
state: JobProgressStates,
soloRun: boolean
): Promise<void> => {
@ -121,7 +121,7 @@ export class JobManager implements IJobListener, IObjectManager {
}
};
getAvailableJobs(): IJob<unknown>[] {
getAvailableJobs(): IJob[] {
return JobRepository.Instance.getAvailableJobs();
}
@ -144,7 +144,7 @@ export class JobManager implements IJobListener, IObjectManager {
Config.Jobs.scheduled.forEach((s): void => this.runSchedule(s));
}
protected findJob<T = unknown>(jobName: string): IJob<T> {
protected findJob(jobName: string): IJob {
return this.getAvailableJobs().find((t): boolean => t.Name === jobName);
}

View File

@ -14,7 +14,7 @@ import {AlbumCoverRestJob} from './jobs/AlbumCoverResetJob';
export class JobRepository {
private static instance: JobRepository = null;
availableJobs: { [key: string]: IJob<unknown> } = {};
availableJobs: { [key: string]: IJob } = {};
public static get Instance(): JobRepository {
if (JobRepository.instance == null) {
@ -23,11 +23,11 @@ export class JobRepository {
return JobRepository.instance;
}
getAvailableJobs(): IJob<unknown>[] {
getAvailableJobs(): IJob[] {
return Object.values(this.availableJobs).filter((t) => t.Supported);
}
register(job: IJob<unknown>): void {
register(job: IJob): void {
if (typeof this.availableJobs[job.Name] !== 'undefined') {
throw new Error('Job already exist:' + job.Name);
}

View File

@ -2,7 +2,7 @@ import {JobDTO} from '../../../../common/entities/job/JobDTO';
import {JobProgress} from './JobProgress';
import {IJobListener} from './IJobListener';
export interface IJob<T> extends JobDTO {
export interface IJob<T extends Record<string, unknown> = Record<string, unknown>> extends JobDTO {
Name: string;
Supported: boolean;
Progress: JobProgress;

View File

@ -4,7 +4,7 @@ import {JobProgressStates} from '../../../../common/entities/job/JobProgressDTO'
export interface IJobListener {
onJobFinished(
job: IJob<unknown>,
job: IJob,
state: JobProgressStates,
soloRun: boolean
): void;

View File

@ -35,7 +35,7 @@ export class ThumbnailGenerationJob extends FileJob<{
): Promise<void> {
if (!config || !config.sizes || !Array.isArray(config.sizes) || config.sizes.length === 0) {
config = config || {};
config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue;
config.sizes = this.ConfigTemplate.find(ct => ct.id == 'sizes').defaultValue as number[];
}
for (const item of config.sizes) {
if (Config.Media.Thumbnail.thumbnailSizes.indexOf(item) === -1) {

View File

@ -1,26 +1,33 @@
import {ConfigTemplateEntry, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {DefaultMessengers, DefaultsJobs,} from '../../../../common/entities/job/JobDTO';
import {Job} from './Job';
import {backendTexts} from '../../../../common/BackendTexts';
import {SortByTypes} from '../../../../common/entities/SortingMethods';
import {DatePatternFrequency, DatePatternSearch, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO';
import {ObjectManagers} from '../../ObjectManagers';
import {PhotoEntity} from '../../database/enitites/PhotoEntity';
import {EmailMediaMessenger} from '../../mediamessengers/EmailMediaMessenger';
import {MediaPickDTO} from '../../../../common/entities/MediaPickDTO';
import {MediaDTOUtils} from '../../../../common/entities/MediaDTO';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
import {MessengerRepository} from '../../messenger/MessengerRepository';
import {Utils} from '../../../../common/Utils';
export class TopPickSendJob extends Job<{
mediaPick: MediaPickDTO[],
messenger: string,
emailTo: string,
emailFrom: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultsJobs[DefaultsJobs['Top Pick Sending']];
public readonly Supported: boolean = true;
public readonly ConfigTemplate: DynamicConfig[] = [
public readonly ConfigTemplate: DynamicConfig[];
private status: 'Listing' | 'Sending' = 'Listing';
private mediaList: PhotoEntity[] = [];
constructor() {
super();
this.ConfigTemplate = [
{
id: 'mediaPick',
type: 'MediaPickDTO-array',
@ -37,27 +44,23 @@ export class TopPickSendJob extends Job<{
pick: 5
}] as MediaPickDTO[],
}, {
id: 'emailTo',
type: 'string-array',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: [],
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
},
id: 'messenger',
type: 'messenger',
name: backendTexts.messenger.name,
description: backendTexts.messenger.description,
defaultValue: DefaultMessengers[DefaultMessengers.Email]
}
];
private status: 'Listing' | 'Sending' = 'Listing';
private mediaList: PhotoEntity[] = [];
// add all messenger's config to the config template
MessengerRepository.Instance.getAll()
.forEach(msgr => Utils.clone(msgr.ConfigTemplate)
.forEach(ct => {
const c = Utils.clone(ct);
c.validIf = {configFiled: 'messenger', equalsValue: msgr.Name};
this.ConfigTemplate.push(c);
}));
}
protected async init(): Promise<void> {
@ -103,17 +106,16 @@ export class TopPickSendJob extends Job<{
private async stepSending(): Promise<boolean> {
if (this.mediaList.length <= 0) {
this.Progress.log('No photos found skipping e-mail sending.');
this.Progress.log('No photos found skipping sending.');
this.Progress.Skipped++;
return false;
}
this.Progress.log('Sending emails of ' + this.mediaList.length + ' photos.');
const messenger = new EmailMediaMessenger();
await messenger.sendMedia({
to: this.config.emailTo,
subject: this.config.emailSubject,
text: this.config.emailText
}, this.mediaList);
const msgr = MessengerRepository.Instance.get(this.config.messenger);
if (!msgr) {
throw new Error('Can\t find "' + this.config.messenger + '" messenger.');
}
this.Progress.log('Sending ' + this.mediaList.length + ' photos.');
await msgr.send(this.config, this.mediaList);
this.Progress.Processed++;
return false;
}

View File

@ -1,18 +1,40 @@
import {createTransport, Transporter} from 'nodemailer';
import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO';
import {Config} from '../../../common/config/private/Config';
import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing';
import {ThumbnailSourceType} from '../fileaccess/PhotoWorker';
import {ProjectPath} from '../../ProjectPath';
import * as path from 'path';
import {PhotoMetadata} from '../../../common/entities/PhotoDTO';
import {Utils} from '../../../common/Utils';
import {QueryParams} from '../../../common/QueryParams';
import {MediaDTOWithThPath, Messenger} from './Messenger';
import {backendTexts} from '../../../common/BackendTexts';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {DefaultMessengers} from '../../../common/entities/job/JobDTO';
export class EmailMediaMessenger {
export class EmailMessenger extends Messenger<{
emailTo: string,
emailSubject: string,
emailText: string,
}> {
public readonly Name = DefaultMessengers[DefaultMessengers.Email];
public readonly ConfigTemplate: DynamicConfig[] = [{
id: 'emailTo',
type: 'string-array',
name: backendTexts.emailTo.name,
description: backendTexts.emailTo.description,
defaultValue: [],
}, {
id: 'emailSubject',
type: 'string',
name: backendTexts.emailSubject.name,
description: backendTexts.emailSubject.description,
defaultValue: 'Latest photos for you',
}, {
id: 'emailText',
type: 'string',
name: backendTexts.emailText.name,
description: backendTexts.emailText.description,
defaultValue: 'I hand picked these photos just for you:',
}];
transporter: Transporter;
constructor() {
super();
this.transporter = createTransport({
host: Config.Messaging.Email.smtp.host,
port: Config.Messaging.Email.smtp.port,
@ -25,24 +47,16 @@ export class EmailMediaMessenger {
});
}
private async getThumbnail(m: MediaDTO) {
return await PhotoProcessing.generateThumbnail(
path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name),
Config.Media.Thumbnail.thumbnailSizes[0],
MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video,
false
);
}
public async sendMedia(mailSettings: {
to: string,
subject: string,
text: string
}, media: MediaDTO[]) {
protected async sendMedia(mailSettings: {
emailTo: string,
emailSubject: string,
emailText: string
}, media: MediaDTOWithThPath[]) {
const attachments = [];
const htmlStart = '<h1 style="text-align: center; margin-bottom: 2em">' + Config.Server.applicationTitle + '</h1>\n' +
'<h3>' + mailSettings.text + '</h3>\n' +
'<h3>' + mailSettings.emailText + '</h3>\n' +
'<table style="margin-left: auto; margin-right: auto;">\n' +
' <tbody>\n';
const htmlEnd = ' </tr>\n' +
@ -51,9 +65,6 @@ export class EmailMediaMessenger {
let htmlMiddle = '';
const numberOfColumns = media.length >= 6 ? 3 : 2;
for (let i = 0; i < media.length; ++i) {
const thPath = await this.getThumbnail(media[i]);
const linkUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) +
'?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name);
const location = (media[i].metadata as PhotoMetadata).positionData?.country ?
(media[i].metadata as PhotoMetadata).positionData?.country :
((media[i].metadata as PhotoMetadata).positionData?.city ?
@ -61,14 +72,14 @@ export class EmailMediaMessenger {
const caption = (new Date(media[i].metadata.creationDate)).getFullYear() + (location ? ', ' + location : '');
attachments.push({
filename: media[i].name,
path: thPath,
path: media[i].thumbnailPath,
cid: 'img' + i
});
if (i % numberOfColumns == 0) {
htmlMiddle += '<tr>';
}
htmlMiddle += '<td>\n' +
' <a style="display: block;text-align: center;" href="' + linkUrl + '"><img alt="' + media[i].name + '" style="max-width: 200px; max-height: 150px; height:auto; width:auto;" src="cid:img' + i + '"/></a>\n' +
' <a style="display: block;text-align: center;" href="' + media[i].thumbnailUrl + '"><img alt="' + media[i].name + '" style="max-width: 200px; max-height: 150px; height:auto; width:auto;" src="cid:img' + i + '"/></a>\n' +
caption +
' </td>\n';
@ -79,8 +90,8 @@ export class EmailMediaMessenger {
return await this.transporter.sendMail({
from: Config.Messaging.Email.emailFrom,
to: mailSettings.to,
subject: mailSettings.subject,
to: mailSettings.emailTo,
subject: mailSettings.emailSubject,
html: htmlStart + htmlMiddle + htmlEnd,
attachments: attachments
});

View File

@ -0,0 +1,50 @@
import {MediaDTO, MediaDTOUtils} from '../../../common/entities/MediaDTO';
import {PhotoProcessing} from '../fileaccess/fileprocessing/PhotoProcessing';
import {ProjectPath} from '../../ProjectPath';
import {Config} from '../../../common/config/private/Config';
import {ThumbnailSourceType} from '../fileaccess/PhotoWorker';
import * as path from 'path';
import {Utils} from '../../../common/Utils';
import {QueryParams} from '../../../common/QueryParams';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
export interface MediaDTOWithThPath extends MediaDTO {
thumbnailPath: string;
thumbnailUrl: string;
}
export abstract class Messenger<C extends Record<string, unknown> = Record<string, unknown>> {
public abstract get Name(): string;
protected config: C;
public readonly ConfigTemplate: DynamicConfig[] = [];
private async getThumbnail(m: MediaDTO) {
return await PhotoProcessing.generateThumbnail(
path.join(ProjectPath.ImageFolder, m.directory.path, m.directory.name, m.name),
Config.Media.Thumbnail.thumbnailSizes[0],
MediaDTOUtils.isPhoto(m) ? ThumbnailSourceType.Photo : ThumbnailSourceType.Video,
false
);
}
public async send(config: C, input: string | MediaDTO[] | unknown) {
if (Array.isArray(input) && input.length > 0
&& (input as MediaDTO[])[0]?.name
&& (input as MediaDTO[])[0]?.directory
&& (input as MediaDTO[])[0]?.metadata?.creationDate) {
const media = input as MediaDTOWithThPath[];
for (let i = 0; i < media.length; ++i) {
media[i].thumbnailPath = await this.getThumbnail(media[i]);
media[i].thumbnailUrl = Utils.concatUrls(Config.Server.publicUrl, '/gallery/', encodeURIComponent(path.join(media[i].directory.path, media[i].directory.name))) +
'?' + QueryParams.gallery.photo + '=' + encodeURIComponent(media[i].name);
}
return await this.sendMedia(config, media);
}
// TODO: implement other branches
throw new Error('Not yet implemented');
}
protected abstract sendMedia(config: C, media: MediaDTOWithThPath[]): Promise<void> ;
}

View File

@ -0,0 +1,34 @@
import {Messenger} from './Messenger';
import {EmailMessenger} from './EmailMessenger';
import {StdoutMessenger} from './StdoutMessenger';
export class MessengerRepository {
private static instance: MessengerRepository = null;
messengers: { [key: string]: Messenger } = {};
public static get Instance(): MessengerRepository {
if (MessengerRepository.instance == null) {
MessengerRepository.instance = new MessengerRepository();
}
return MessengerRepository.instance;
}
getAll(): Messenger[] {
return Object.values(this.messengers);
}
register(msgr: Messenger): void {
if (typeof this.messengers[msgr.Name] !== 'undefined') {
throw new Error('Messenger already exist:' + msgr.Name);
}
this.messengers[msgr.Name] = msgr;
}
get(name: string): Messenger {
return this.messengers[name];
}
}
MessengerRepository.Instance.register(new EmailMessenger());
MessengerRepository.Instance.register(new StdoutMessenger());

View File

@ -0,0 +1,17 @@
import {MediaDTOWithThPath, Messenger} from './Messenger';
import {DynamicConfig} from '../../../common/entities/DynamicConfig';
import {DefaultMessengers} from '../../../common/entities/job/JobDTO';
export class StdoutMessenger extends Messenger {
public readonly Name = DefaultMessengers[DefaultMessengers.Stdout];
public readonly ConfigTemplate: DynamicConfig[] = [];
constructor() {
super();
}
protected async sendMedia(config: never, media: MediaDTOWithThPath[]) {
console.log(media.map(m => m.thumbnailPath));
}
}

View File

@ -10,6 +10,7 @@ export class AdminRouter {
this.addGetStatistic(app);
this.addGetDuplicates(app);
this.addJobs(app);
this.addMessengers(app);
}
private static addGetStatistic(app: Express): void {
@ -32,6 +33,15 @@ export class AdminRouter {
);
}
private static addMessengers(app: Express): void {
app.get(
Config.Server.apiPath + '/admin/messengers/available',
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.Admin),
AdminMWs.getAvailableMessengers,
RenderingMWs.renderResult
);
}
private static addJobs(app: Express): void {
app.get(
Config.Server.apiPath + '/admin/jobs/available',

View File

@ -1,4 +1,5 @@
export type backendText = number;
// keep the numbering sparse to support later addition
export const backendTexts = {
indexedFilesOnly: {name: 10, description: 12},
sizeToGenerate: {name: 20, description: 22},
@ -6,6 +7,7 @@ export const backendTexts = {
mediaPick: {name: 40, description: 42},
emailTo: {name: 70, description: 72},
emailSubject: {name: 90, description: 92},
emailText: {name: 100, description: 102}
emailText: {name: 100, description: 102},
messenger: {name: 110,description: 112}
};

View File

@ -1,5 +1,8 @@
import {backendText} from '../BackendTexts';
import {fieldType} from './job/JobDTO';
export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array' | 'messenger';
/**
* Dynamic configs are not part of the typeconfig maintained config.
@ -14,4 +17,5 @@ export interface DynamicConfig {
description: backendText | string;
type: fieldType;
defaultValue: unknown;
validIf?: { configFiled: string, equalsValue: string }; // only shows this config if this predicate is true
}

View File

@ -1,8 +1,5 @@
import {backendText} from '../../BackendTexts';
import {DynamicConfig} from '../DynamicConfig';
export type fieldType = 'string' | 'string-array' | 'number' | 'boolean' | 'number-array' | 'MediaPickDTO-array';
export enum DefaultsJobs {
Indexing = 1,
'Gallery Reset' = 2,
@ -19,6 +16,12 @@ export enum DefaultsJobs {
}
export enum DefaultMessengers {
Email = 1,
Stdout = 2
}
export interface JobDTO {
Name: string;
ConfigTemplate: DynamicConfig[];

View File

@ -5,7 +5,10 @@ import {DefaultsJobs} from '../../../common/entities/job/JobDTO';
@Injectable()
export class BackendtextService {
public get(id: backendText): string {
public get(id: backendText | string): string {
if (typeof id === 'string') {
return id;
}
switch (id) {
case backendTexts.sizeToGenerate.name:
return $localize`Size to generate`;

View File

@ -3,9 +3,10 @@ import {BehaviorSubject} from 'rxjs';
import {JobProgressDTO, JobProgressStates, OnTimerJobProgressDTO,} from '../../../../common/entities/job/JobProgressDTO';
import {NetworkService} from '../../model/network/network.service';
import {JobScheduleDTO} from '../../../../common/entities/job/JobScheduleDTO';
import {ConfigTemplateEntry, JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO';
import {JobDTO, JobDTOUtils} from '../../../../common/entities/job/JobDTO';
import {BackendtextService} from '../../model/backendtext.service';
import {NotificationService} from '../../model/notification.service';
import {DynamicConfig} from '../../../../common/entities/DynamicConfig';
@Injectable()
export class ScheduledJobsService {
@ -13,6 +14,7 @@ export class ScheduledJobsService {
public onJobFinish: EventEmitter<string> = new EventEmitter<string>();
timer: number = null;
public availableJobs: BehaviorSubject<JobDTO[]>;
public availableMessengers: BehaviorSubject<string[]>;
public jobStartingStopping: { [key: string]: boolean } = {};
private subscribers = 0;
@ -23,6 +25,7 @@ export class ScheduledJobsService {
) {
this.progress = new BehaviorSubject({});
this.availableJobs = new BehaviorSubject([]);
this.availableMessengers = new BehaviorSubject([]);
}
@ -32,7 +35,13 @@ export class ScheduledJobsService {
);
}
public getConfigTemplate(JobName: string): ConfigTemplateEntry[] {
public async getAvailableMessengers(): Promise<void> {
this.availableMessengers.next(
await this.networkService.getJson<string[]>('/admin/messengers/available')
);
}
public getConfigTemplate(JobName: string): DynamicConfig[] {
const job = this.availableJobs.value.find(
(t) => t.Name === JobName
);

View File

@ -170,6 +170,7 @@
<div *ngFor="let configEntry of jobsService.getConfigTemplate(schedule.jobName)">
<div class="mb-1 row"
*ngIf="!configEntry.validIf || schedule.config[configEntry.validIf.configFiled] == configEntry.validIf.equalsValue"
[class.mb-3]="settingsService.configStyle == ConfigStyle.full">
<label class="col-md-2 control-label"
[for]="configEntry.id+'_'+i">{{backendTextService.get(configEntry.name)}}</label>
@ -227,6 +228,18 @@
placeholder="Search Query">
</app-gallery-search-field>
<select
*ngSwitchCase="'messenger'"
[id]="configEntry.id+'_'+i"
[name]="configEntry.id+'_'+i"
(ngModelChange)="onChange($event)"
[(ngModel)]="schedule.config[configEntry.id]"
class="form-select">
<option *ngFor="let msg of jobsService.availableMessengers | async" [ngValue]="msg">{{msg}}
</option>
</select>
<ng-container *ngSwitchCase="'MediaPickDTO-array'">
<ng-container *ngFor="let mp of AsMediaPickDTOArray(schedule.config[configEntry.id]); let j=index">

View File

@ -108,6 +108,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
ngOnInit(): void {
this.jobsService.subscribeToProgress();
this.jobsService.getAvailableJobs().catch(console.error);
this.jobsService.getAvailableMessengers().catch(console.error);
}
ngOnDestroy(): void {
@ -128,7 +129,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
schedule.config = schedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (schedule.config[ct.id] = ct.defaultValue)
(ct) => (schedule.config[ct.id] = ct.defaultValue as never)
);
}
}
@ -216,7 +217,7 @@ export class WorkflowComponent implements ControlValueAccessor, Validator, OnIni
this.newSchedule.config = this.newSchedule.config || {};
if (job.ConfigTemplate) {
job.ConfigTemplate.forEach(
(ct) => (this.newSchedule.config[ct.id] = ct.defaultValue)
(ct) => (this.newSchedule.config[ct.id] = ct.defaultValue as never)
);
}
this.jobModalQL.first.show();