diff --git a/src/backend/middlewares/admin/ExtensionMWs.ts b/src/backend/middlewares/admin/ExtensionMWs.ts index a244fb3c..5ca0baa4 100644 --- a/src/backend/middlewares/admin/ExtensionMWs.ts +++ b/src/backend/middlewares/admin/ExtensionMWs.ts @@ -79,4 +79,96 @@ export class ExtensionMWs { ); } } + + public static async reloadExtension( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + // Extract the config key (folder name) from the request body + // Note: The request parameter is called 'path' for backwards compatibility, + // but it represents the config key used in Config.Extensions.extensions + const configKey = req.body.path; + + if (!configKey) { + return next( + new ErrorDTO( + ErrorCodes.INPUT_ERROR, + 'Extension config key is required' + ) + ); + } + + // Call cleanUp and init on the ExtensionManager + await ObjectManagers.getInstance().ExtensionManager.reloadExtension(configKey); + + // Set the result to an empty object (success) + req.resultPipe = { success: true }; + return next(); + } catch (err) { + if (err instanceof Error) { + return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Extension reload error: ' + err.toString(), + err + ) + ); + } + return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Extension reload error: ' + JSON.stringify(err, null, ' '), + err + ) + ); + } + } + + public static async deleteExtension( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + // Extract the config key (folder name) from the request body + // Note: The request parameter is called 'path' for backwards compatibility, + // but it represents the config key used in Config.Extensions.extensions + const configKey = req.body.path; + + if (!configKey) { + return next( + new ErrorDTO( + ErrorCodes.INPUT_ERROR, + 'Extension config key is required' + ) + ); + } + + // Call deleteExtension on the ExtensionManager to cleanup and remove folder + await ObjectManagers.getInstance().ExtensionManager.deleteExtension(configKey); + + // Set the result to an empty object (success) + req.resultPipe = { success: true }; + return next(); + } catch (err) { + if (err instanceof Error) { + return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Extension deletion error: ' + err.toString(), + err + ) + ); + } + return next( + new ErrorDTO( + ErrorCodes.JOB_ERROR, + 'Extension deletion error: ' + JSON.stringify(err, null, ' '), + err + ) + ); + } + } } diff --git a/src/backend/model/extension/ExtensionConfig.ts b/src/backend/model/extension/ExtensionConfig.ts index 718f2131..e066d5aa 100644 --- a/src/backend/model/extension/ExtensionConfig.ts +++ b/src/backend/model/extension/ExtensionConfig.ts @@ -4,12 +4,15 @@ import {ServerExtensionsEntryConfig} from '../../../common/config/private/subcon export class ExtensionConfig implements IExtensionConfig { - constructor(private readonly extensionFolder: string) { + /** + * @param configKey - The key used in Config.Extensions.extensions map (matches the extension's folder name) + */ + constructor(private readonly configKey: string) { } public getConfig(): C { - const c = Config.Extensions.extensions[this.extensionFolder] as ServerExtensionsEntryConfig; + const c = Config.Extensions.extensions[this.configKey] as ServerExtensionsEntryConfig; return c?.configs as C; } diff --git a/src/backend/model/extension/ExtensionConfigTemplateLoader.ts b/src/backend/model/extension/ExtensionConfigTemplateLoader.ts index 373f685c..f4fbd8dd 100644 --- a/src/backend/model/extension/ExtensionConfigTemplateLoader.ts +++ b/src/backend/model/extension/ExtensionConfigTemplateLoader.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import {ServerExtensionsEntryConfig} from '../../../common/config/private/subconfigs/ServerExtensionsConfig'; import {ProjectPath} from '../../ProjectPath'; +import {Utils} from '../../../common/Utils'; /** @@ -14,7 +15,6 @@ export class ExtensionConfigTemplateLoader { private static instance: ExtensionConfigTemplateLoader; - private loaded = false; private extensionList: string[] = []; private extensionTemplates: { folder: string, template?: { new(): unknown } }[] = []; @@ -26,44 +26,6 @@ export class ExtensionConfigTemplateLoader { return this.instance; } - - - /** - * Loads a single extension template - * @param extFolder The folder name of the extension - * @returns The extension template object if the extension is valid, null otherwise - */ - private loadSingleExtensionTemplate(extFolder: string): { folder: string, template?: { new(): unknown } } | null { - if (!ProjectPath.ExtensionFolder) { - throw new Error('Unknown extensions folder.'); - } - - const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); - const configExtPath = path.join(extPath, 'config.js'); - const serverExtPath = path.join(extPath, 'server.js'); - - // if server.js is missing, it's not a valid extension - if (!fs.existsSync(serverExtPath)) { - return null; - } - - let template: { folder: string, template?: { new(): unknown } } = { folder: extFolder }; - - if (fs.existsSync(configExtPath)) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const extCfg = require(configExtPath); - if (typeof extCfg?.initConfig === 'function') { - extCfg?.initConfig({ - setConfigTemplate: (templateClass: { new(): unknown }): void => { - template = { folder: extFolder, template: templateClass }; - } - }); - } - } - - return template; - } - /** * Loads a single extension template and adds it to the config * @param extFolder The folder name of the extension @@ -99,31 +61,76 @@ export class ExtensionConfigTemplateLoader { if (!ProjectPath.ExtensionFolder) { throw new Error('Unknown extensions folder.'); } - // already loaded - if (!this.loaded) { - this.extensionTemplates = []; - if (fs.existsSync(ProjectPath.ExtensionFolder)) { - this.extensionList = (fs - .readdirSync(ProjectPath.ExtensionFolder)) - .filter((f): boolean => - fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() - ); - this.extensionList.sort(); - for (let i = 0; i < this.extensionList.length; ++i) { - const extFolder = this.extensionList[i]; - const template = this.loadSingleExtensionTemplate(extFolder); - if (template) { - this.extensionTemplates.push(template); - } + if (!fs.existsSync(ProjectPath.ExtensionFolder)) { + return; + } + + + const newList = this.getExtensionFolders(); + const loaded = Utils.equalsFilter(this.extensionList, newList); + this.extensionList = newList; + // already loaded + if (!loaded) { + this.extensionTemplates = []; + + for (let i = 0; i < this.extensionList.length; ++i) { + const extFolder = this.extensionList[i]; + const template = this.loadSingleExtensionTemplate(extFolder); + if (template) { + this.extensionTemplates.push(template); } } - this.loaded = true; } this.setTemplatesToConfig(config); } + /** + * Loads a single extension template + * @param extFolder The folder name of the extension + * @returns The extension template object if the extension is valid, null otherwise + */ + private loadSingleExtensionTemplate(extFolder: string): { folder: string, template?: { new(): unknown } } | null { + if (!ProjectPath.ExtensionFolder) { + throw new Error('Unknown extensions folder.'); + } + + const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); + const configExtPath = path.join(extPath, 'config.js'); + const serverExtPath = path.join(extPath, 'server.js'); + + // if server.js is missing, it's not a valid extension + if (!fs.existsSync(serverExtPath)) { + return null; + } + + let template: { folder: string, template?: { new(): unknown } } = {folder: extFolder}; + + if (fs.existsSync(configExtPath)) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const extCfg = require(configExtPath); + if (typeof extCfg?.initConfig === 'function') { + extCfg?.initConfig({ + setConfigTemplate: (templateClass: { new(): unknown }): void => { + template = {folder: extFolder, template: templateClass}; + } + }); + } + } + + return template; + } + + private getExtensionFolders() { + const list = (fs + .readdirSync(ProjectPath.ExtensionFolder)) + .filter((f): boolean => + fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory() + ); + list.sort(); + return list; + } private setTemplatesToConfig(config: PrivateConfigClass) { if (!this.extensionTemplates) { diff --git a/src/backend/model/extension/ExtensionManager.ts b/src/backend/model/extension/ExtensionManager.ts index 522d56e9..93c77252 100644 --- a/src/backend/model/extension/ExtensionManager.ts +++ b/src/backend/model/extension/ExtensionManager.ts @@ -21,6 +21,7 @@ import {ExtensionListItem} from '../../../common/entities/extension/ExtensionLis import {ExtensionConfigTemplateLoader} from './ExtensionConfigTemplateLoader'; import {Utils} from '../../../common/Utils'; import {UIExtensionDTO} from '../../../common/entities/extension/IClientUIConfig'; +import {ExtensionConfigWrapper} from './ExtensionConfigWrapper'; // eslint-disable-next-line @typescript-eslint/no-var-requires const exec = util.promisify(require('child_process').exec); const LOG_TAG = '[ExtensionManager]'; @@ -77,6 +78,10 @@ export class ExtensionManager implements IObjectManager { this.extObjects = {}; } + /** + * Install an extension from the repository + * @param extensionId - The repository extension ID (not to be confused with the unique internal extensionId used in extObjects) + */ public async installExtension(extensionId: string): Promise { if (!Config.Extensions.enabled) { throw new Error('Extensions are disabled'); @@ -131,6 +136,83 @@ export class ExtensionManager implements IObjectManager { Logger.debug(LOG_TAG, `Extension ${extensionId} installed successfully`); } + /** + * Reload an extension by cleaning up and re-initializing it + * @param configKey - The key used in Config.Extensions.extensions map (typically the folder name) + */ + public async reloadExtension(configKey: string): Promise { + if (!Config.Extensions.enabled) { + throw new Error('Extensions are disabled'); + } + + Logger.debug(LOG_TAG, `Reloading extension with config key: ${configKey}`); + + // Find the unique extension ID by matching the folder name + let uniqueExtensionId: string = null; + for (const id of Object.keys(this.extObjects)) { + if (this.extObjects[id].folder === configKey) { + uniqueExtensionId = id; + break; + } + } + + if (!uniqueExtensionId) { + throw new Error(`Extension with config key ${configKey} not found in configuration`); + } + + // Clean up the extension + await this.cleanUpSingleExtension(uniqueExtensionId); + + // Re-initialize the extension + await this.initSingleExtension(configKey); + + Logger.debug(LOG_TAG, `Extension ${uniqueExtensionId} reloaded successfully`); + } + + /** + * Delete an extension by cleaning up, removing its folder, and removing it from configuration + * @param configKey - The key used in Config.Extensions.extensions map (typically the folder name) + */ + public async deleteExtension(configKey: string): Promise { + if (!Config.Extensions.enabled) { + throw new Error('Extensions are disabled'); + } + + Logger.debug(LOG_TAG, `Deleting extension with config key: ${configKey}`); + + // Find the unique extension ID by matching the folder name + let uniqueExtensionId: string = null; + for (const id of Object.keys(this.extObjects)) { + if (this.extObjects[id].folder === configKey) { + uniqueExtensionId = id; + break; + } + } + + if (!uniqueExtensionId) { + // The extension does not have an extension object, probably it had no init function + Logger.silly(LOG_TAG, `Extension with config key ${configKey} not found in configuration`); + }else{ + // Clean up the extension + await this.cleanUpSingleExtension(uniqueExtensionId); + } + + // Remove the extension folder + const extPath = path.join(ProjectPath.ExtensionFolder, configKey); + if (fs.existsSync(extPath)) { + Logger.silly(LOG_TAG, `Removing extension folder: ${extPath}`); + fs.rmSync(extPath, { recursive: true, force: true }); + } + + // Remove from configuration + const original = await ExtensionConfigWrapper.original(); + original.Extensions.extensions.removeProperty(configKey); + await original.save(); + Config.Extensions.extensions.removeProperty(configKey); + + Logger.debug(LOG_TAG, `Extension ${configKey} deleted successfully`); + } + getUIExtensionConfigs(): UIExtensionDTO[] { return Object.values(this.extObjects) .filter(obj => !!obj.ui?.buttonConfigs?.length) @@ -166,45 +248,45 @@ export class ExtensionManager implements IObjectManager { ExtensionDecoratorObject.init(this.events); } - private createUniqueExtensionObject(name: string, folder: string): IExtensionObject { - let id = name; - if (this.extObjects[id]) { + private createUniqueExtensionObject(extensionName: string, folderName: string): IExtensionObject { + let uniqueExtensionId = extensionName; + if (this.extObjects[uniqueExtensionId]) { let i = 0; - while (this.extObjects[`${name}_${++i}`]) { /* empty */ + while (this.extObjects[`${extensionName}_${++i}`]) { /* empty */ } - id = `${name}_${++i}`; + uniqueExtensionId = `${extensionName}_${++i}`; } - if (!this.extObjects[id]) { - this.extObjects[id] = new ExtensionObject(id, name, folder, this.router, this.events); + if (!this.extObjects[uniqueExtensionId]) { + this.extObjects[uniqueExtensionId] = new ExtensionObject(uniqueExtensionId, extensionName, folderName, this.router, this.events); } - return this.extObjects[id]; + return this.extObjects[uniqueExtensionId]; } /** * Initialize a single extension - * @param extId The id of the extension + * @param configKey The key used in Config.Extensions.extensions map (typically the folder name) * @returns Promise that resolves when the extension is initialized */ - private async initSingleExtension(extId: string): Promise { - const extConf: ServerExtensionsEntryConfig = Config.Extensions.extensions[extId] as ServerExtensionsEntryConfig; + private async initSingleExtension(configKey: string): Promise { + const extConf: ServerExtensionsEntryConfig = Config.Extensions.extensions[configKey] as ServerExtensionsEntryConfig; if (!extConf) { - Logger.silly(LOG_TAG, `Skipping ${extId} initiation. Extension config is missing.`); + Logger.silly(LOG_TAG, `Skipping ${configKey} initiation. Extension config is missing.`); return; } - const extFolder = extConf.path; - let extName = extFolder; + const folderName = extConf.path; + let extName = folderName; if (extConf.enabled === false) { - Logger.silly(LOG_TAG, `Skipping ${extFolder} initiation. Extension is disabled.`); + Logger.silly(LOG_TAG, `Skipping ${folderName} initiation. Extension is disabled.`); return; } - const extPath = path.join(ProjectPath.ExtensionFolder, extFolder); + const extPath = path.join(ProjectPath.ExtensionFolder, folderName); const serverExtPath = path.join(extPath, 'server.js'); const packageJsonPath = path.join(extPath, 'package.json'); if (!fs.existsSync(serverExtPath)) { - Logger.silly(LOG_TAG, `Skipping ${extFolder} server initiation. server.js does not exists`); + Logger.silly(LOG_TAG, `Skipping ${folderName} server initiation. server.js does not exists`); return; } @@ -227,8 +309,8 @@ export class ExtensionManager implements IObjectManager { // eslint-disable-next-line @typescript-eslint/no-var-requires const ext = require(serverExtPath); if (typeof ext?.init === 'function') { - Logger.debug(LOG_TAG, 'Running init on extension: ' + extFolder); - await ext?.init(this.createUniqueExtensionObject(extName, extFolder)); + Logger.debug(LOG_TAG, 'Running init on extension: ' + folderName); + await ext?.init(this.createUniqueExtensionObject(extName, folderName)); } } @@ -246,16 +328,31 @@ export class ExtensionManager implements IObjectManager { } } - private async cleanUpExtensions() { - for (const extObj of Object.values(this.extObjects)) { - const serverExt = path.join(extObj.folder, 'server.js'); + private async cleanUpSingleExtension(uniqueExtensionId: string): Promise { + const extObj = this.extObjects[uniqueExtensionId]; + if (!extObj) { + Logger.silly(LOG_TAG, `Extension ${uniqueExtensionId} not found in extObjects, skipping cleanup`); + return; + } + + const serverExt = path.join(extObj.folder, 'server.js'); + if (fs.existsSync(serverExt)) { // eslint-disable-next-line @typescript-eslint/no-var-requires const ext = require(serverExt); if (typeof ext?.cleanUp === 'function') { - Logger.debug(LOG_TAG, 'Running Init on extension:' + extObj.extensionName); + Logger.debug(LOG_TAG, 'Running cleanUp on extension: ' + extObj.extensionName); await ext?.cleanUp(extObj); } - extObj.messengers.cleanUp(); + } + extObj.messengers.cleanUp(); + + // Remove from extObjects + delete this.extObjects[uniqueExtensionId]; + } + + private async cleanUpExtensions() { + for (const uniqueExtensionId of Object.keys(this.extObjects)) { + await this.cleanUpSingleExtension(uniqueExtensionId); } } diff --git a/src/backend/model/extension/ExtensionObject.ts b/src/backend/model/extension/ExtensionObject.ts index 73a06d05..25b482a6 100644 --- a/src/backend/model/extension/ExtensionObject.ts +++ b/src/backend/model/extension/ExtensionObject.ts @@ -21,6 +21,13 @@ export class ExtensionObject implements IExtensionObject { public readonly messengers; public readonly ui; + /** + * @param extensionId - Unique ID used internally to track this extension instance (may have _1, _2 suffix if name collision occurs) + * @param extensionName - Display name of the extension (typically from package.json) + * @param folder - Folder name where the extension is stored (also used as config key in Config.Extensions.extensions) + * @param extensionRouter - Express router for extension REST API endpoints + * @param events - Extension events for hooking into gallery functionality + */ constructor(public readonly extensionId: string, public readonly extensionName: string, public readonly folder: string, diff --git a/src/backend/model/extension/UIExtension.ts b/src/backend/model/extension/UIExtension.ts index d60a23df..c0ebefe4 100644 --- a/src/backend/model/extension/UIExtension.ts +++ b/src/backend/model/extension/UIExtension.ts @@ -16,8 +16,8 @@ export class UIExtension implements IUIExtension { public addMediaButton(buttonConfig: IClientMediaButtonConfig, serverSB: (params: ParamsDictionary, body: any, user: UserDTO, media: MediaEntity, repository: Repository) => Promise): void { this.buttonConfigs.push(buttonConfig); // api path isn't set - if (!buttonConfig.apiPath) { - Logger.silly('[UIExtension]', 'Button config has no apiPath:' + buttonConfig.name); + if (!buttonConfig.apiPath && serverSB) { + Logger.warn('[UIExtension]', `Button config ${buttonConfig.name} has no apiPath, but has callback function. This is not supported.`); return; } this.extensionObject.RESTApi.post.mediaJsonResponse([buttonConfig.apiPath], buttonConfig.minUserRole || UserRoles.LimitedGuest, !buttonConfig.skipDirectoryInvalidation, serverSB); diff --git a/src/backend/routes/admin/ExtensionRouter.ts b/src/backend/routes/admin/ExtensionRouter.ts index 2a620815..009fa160 100644 --- a/src/backend/routes/admin/ExtensionRouter.ts +++ b/src/backend/routes/admin/ExtensionRouter.ts @@ -10,6 +10,8 @@ export class ExtensionRouter { public static route(app: Express): void { this.addExtensionList(app); this.addExtensionInstall(app); + this.addExtensionReload(app); + this.addExtensionDelete(app); } private static addExtensionList(app: Express): void { @@ -34,4 +36,26 @@ export class ExtensionRouter { ); } + private static addExtensionReload(app: Express): void { + app.post( + [ExtensionManager.EXTENSION_API_PATH+'/reload'], + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + ServerTimingMWs.addServerTiming, + ExtensionMWs.reloadExtension, + RenderingMWs.renderResult + ); + } + + private static addExtensionDelete(app: Express): void { + app.post( + [ExtensionManager.EXTENSION_API_PATH+'/delete'], + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + ServerTimingMWs.addServerTiming, + ExtensionMWs.deleteExtension, + RenderingMWs.renderResult + ); + } + } diff --git a/src/frontend/app/ui/settings/extension-installer/extension-installer.service.ts b/src/frontend/app/ui/settings/extension-installer/extension-installer.service.ts index 5fdc1960..0679e1d2 100644 --- a/src/frontend/app/ui/settings/extension-installer/extension-installer.service.ts +++ b/src/frontend/app/ui/settings/extension-installer/extension-installer.service.ts @@ -17,4 +17,12 @@ export class ExtensionInstallerService { public installExtension(extensionId: string): Promise { return this.networkService.postJson('/extension/install', {id: extensionId}); } + + public reloadExtension(extensionPath: string): Promise { + return this.networkService.postJson('/extension/reload', {path: extensionPath}); + } + + public deleteExtension(extensionPath: string): Promise { + return this.networkService.postJson('/extension/delete', {path: extensionPath}); + } } diff --git a/src/frontend/app/ui/settings/template/template.component.html b/src/frontend/app/ui/settings/template/template.component.html index 41d33dc3..bfa06a6f 100644 --- a/src/frontend/app/ui/settings/template/template.component.html +++ b/src/frontend/app/ui/settings/template/template.component.html @@ -82,7 +82,8 @@ - +
{{ rStates?.value.__state[ck].tags?.name || ck }}
@@ -98,7 +99,8 @@
- +
-
+
{{ rStates?.value.__state[ck].tags?.name || ck }}

+
+ + +
& WebConfig) => ConfigState; private subscription: Subscription = null; private settingsSubscription: Subscription = null; - protected sliceFN?: (s: IWebConfigClassPrivate & WebConfig) => ConfigState; - - public readonly ConfigStyle = ConfigStyle; constructor( protected authService: AuthenticationService, @@ -90,9 +89,22 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting protected notification: NotificationService, public settingsService: SettingsService, public jobsService: ScheduledJobsService, + private extensionService: ExtensionInstallerService, ) { } + get Name(): string { + return this.changed ? this.name + '*' : this.name; + } + + get Changed(): boolean { + return this.changed; + } + + get HasAvailableSettings(): boolean { + return !this.states?.shouldHide || !this.states?.shouldHide(); + } + ngOnChanges(): void { if (!this.ConfigPath) { this.setSliceFN(c => ({value: c as any, isConfigType: true, type: WebConfig} as any)); @@ -134,7 +146,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } - ngOnDestroy(): void { if (this.subscription != null) { this.subscription.unsubscribe(); @@ -144,7 +155,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } } - setSliceFN(sliceFN?: (s: IWebConfigClassPrivate & WebConfig) => ConfigState) { if (sliceFN) { this.sliceFN = sliceFN; @@ -154,18 +164,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } } - get Name(): string { - return this.changed ? this.name + '*' : this.name; - } - - get Changed(): boolean { - return this.changed; - } - - get HasAvailableSettings(): boolean { - return !this.states?.shouldHide || !this.states?.shouldHide(); - } - onNewSettings = (s: IWebConfigClassPrivate & WebConfig) => { this.states = this.sliceFN(s.clone()) as RecursiveState; @@ -289,7 +287,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return c.isConfigArrayType && !CustomSettingsEntries.iS(c); } - public async save(): Promise { this.inProgress = true; this.error = ''; @@ -314,11 +311,6 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting return false; } - private async getSettings(): Promise { - await this.settingsService.getSettings(); - this.changed = false; - } - getKeys(states: any): string[] { if (states.keys) { return states.keys; @@ -356,4 +348,50 @@ export class TemplateComponent implements OnInit, OnChanges, OnDestroy, ISetting } return this.jobsService.progress.value[JobDTOUtils.getHashName(uiJob.job, uiJob.config || {})]; } + + protected isExtension(c: ConfigState): boolean { + return (c?.value?.__propPath as any).substring(0, (c?.value?.__propPath as any).lastIndexOf('.')) == 'Extensions.extensions'; + } + + protected async removeExtension(c: ConfigState): Promise { + const extensionPath = c.value.__state['path'].value; + try { + this.inProgress = true; + await this.extensionService.deleteExtension(extensionPath); + await this.settingsService.getSettings(); + this.notification.success( + $localize`Extension deleted successfully`, + $localize`Success` + ); + } catch (err) { + console.error(err); + this.notification.error($localize`Failed to delete extension`, err); + } finally { + this.inProgress = false; + } + } + + + protected async reloadExtension(c: ConfigState): Promise { + const extensionPath = c.value.__state['path'].value; + try { + this.inProgress = true; + await this.extensionService.reloadExtension(extensionPath); + await this.settingsService.getSettings(); + this.notification.success( + $localize`Extension reloaded successfully`, + $localize`Success` + ); + } catch (err) { + console.error(err); + this.notification.error($localize`Failed to reload extension`, err); + } finally { + this.inProgress = false; + } + } + + private async getSettings(): Promise { + await this.settingsService.getSettings(); + this.changed = false; + } } diff --git a/src/frontend/main.ts b/src/frontend/main.ts index b5eb6a40..6b49fc87 100644 --- a/src/frontend/main.ts +++ b/src/frontend/main.ts @@ -1,64 +1,141 @@ -import { enableProdMode, Injectable, importProvidersFrom } from '@angular/core'; -import { environment } from './environments/environment'; -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi, HttpClient } from '@angular/common/http'; -import { ErrorInterceptor } from './app/model/network/helper/error.interceptor'; -import { UrlSerializer, DefaultUrlSerializer, UrlTree } from '@angular/router'; -import { HAMMER_GESTURE_CONFIG, HammerGestureConfig, BrowserModule, HammerModule, bootstrapApplication } from '@angular/platform-browser'; -import { StringifySortingMethod } from './app/pipes/StringifySortingMethod'; -import { NetworkService } from './app/model/network/network.service'; -import { ShareService } from './app/ui/gallery/share.service'; -import { UserService } from './app/model/network/user.service'; -import { AlbumsService } from './app/ui/albums/albums.service'; -import { GalleryCacheService } from './app/ui/gallery/cache.gallery.service'; -import { ContentService } from './app/ui/gallery/content.service'; -import { ContentLoaderService } from './app/ui/gallery/contentLoader.service'; -import { FilterService } from './app/ui/gallery/filter/filter.service'; -import { GallerySortingService } from './app/ui/gallery/navigator/sorting.service'; -import { GalleryNavigatorService } from './app/ui/gallery/navigator/navigator.service'; -import { MapService } from './app/ui/gallery/map/map.service'; -import { BlogService } from './app/ui/gallery/blog/blog.service'; -import { SearchQueryParserService } from './app/ui/gallery/search/search-query-parser.service'; -import { AutoCompleteService } from './app/ui/gallery/search/autocomplete.service'; -import { AuthenticationService } from './app/model/network/authentication.service'; -import { ThumbnailLoaderService } from './app/ui/gallery/thumbnailLoader.service'; -import { ThumbnailManagerService } from './app/ui/gallery/thumbnailManager.service'; -import { NotificationService } from './app/model/notification.service'; -import { FullScreenService } from './app/ui/gallery/fullscreen.service'; -import { NavigationService } from './app/model/navigation.service'; -import { SettingsService } from './app/ui/settings/settings.service'; -import { SeededRandomService } from './app/model/seededRandom.service'; -import { OverlayService } from './app/ui/gallery/overlay.service'; -import { QueryService } from './app/model/query.service'; -import { ThemeService } from './app/model/theme.service'; -import { DuplicateService } from './app/ui/duplicates/duplicates.service'; -import { FacesService } from './app/ui/faces/faces.service'; -import { VersionService } from './app/model/version.service'; -import { ScheduledJobsService } from './app/ui/settings/scheduled-jobs.service'; -import { BackendtextService } from './app/model/backendtext.service'; -import { CookieService } from 'ngx-cookie-service'; -import { GPXFilesFilterPipe } from './app/pipes/GPXFilesFilterPipe'; -import { MDFilesFilterPipe } from './app/pipes/MDFilesFilterPipe'; -import { FileSizePipe } from './app/pipes/FileSizePipe'; -import { DatePipe } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { provideAnimations } from '@angular/platform-browser/animations'; -import { AppRoutingModule } from './app/app.routing'; -import { NgIconsModule } from '@ng-icons/core'; -import { ionDownloadOutline, ionFunnelOutline, ionGitBranchOutline, ionArrowDownOutline, ionArrowUpOutline, ionStarOutline, ionStar, ionCalendarOutline, ionPersonOutline, ionShuffleOutline, ionPeopleOutline, ionMenuOutline, ionShareSocialOutline, ionImagesOutline, ionLinkOutline, ionSearchOutline, ionHammerOutline, ionCopyOutline, ionAlbumsOutline, ionSettingsOutline, ionLogOutOutline, ionChevronForwardOutline, ionChevronDownOutline, ionChevronBackOutline, ionTrashOutline, ionSaveOutline, ionAddOutline, ionRemoveOutline, ionTextOutline, ionFolderOutline, ionDocumentOutline, ionDocumentTextOutline, ionImageOutline, ionPricetagOutline, ionLocationOutline, ionSunnyOutline, ionMoonOutline, ionVideocamOutline, ionInformationCircleOutline, ionInformationOutline, ionContractOutline, ionExpandOutline, ionCloseOutline, ionTimerOutline, ionPlayOutline, ionPauseOutline, ionVolumeMediumOutline, ionVolumeMuteOutline, ionCameraOutline, ionWarningOutline, ionLockClosedOutline, ionChevronUpOutline, ionFlagOutline, ionGlobeOutline, ionPieChartOutline, ionStopOutline, ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList, ionPencil } from '@ng-icons/ionicons'; -import { ClipboardModule } from 'ngx-clipboard'; -import { TooltipModule } from 'ngx-bootstrap/tooltip'; -import { ToastrModule } from 'ngx-toastr'; -import { ModalModule } from 'ngx-bootstrap/modal'; -import { CollapseModule } from 'ngx-bootstrap/collapse'; -import { PopoverModule } from 'ngx-bootstrap/popover'; -import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; -import { BsDatepickerModule } from 'ngx-bootstrap/datepicker'; -import { TimepickerModule } from 'ngx-bootstrap/timepicker'; -import { LoadingBarModule } from '@ngx-loading-bar/core'; -import { LeafletModule } from '@bluehalo/ngx-leaflet'; -import { LeafletMarkerClusterModule } from '@bluehalo/ngx-leaflet-markercluster'; -import { MarkdownModule } from 'ngx-markdown'; -import { AppComponent } from './app/app.component'; +import {enableProdMode, importProvidersFrom, Injectable} from '@angular/core'; +import {environment} from './environments/environment'; +import {HTTP_INTERCEPTORS, HttpClient, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; +import {ErrorInterceptor} from './app/model/network/helper/error.interceptor'; +import {DefaultUrlSerializer, UrlSerializer, UrlTree} from '@angular/router'; +import {bootstrapApplication, BrowserModule, HAMMER_GESTURE_CONFIG, HammerGestureConfig, HammerModule} from '@angular/platform-browser'; +import {StringifySortingMethod} from './app/pipes/StringifySortingMethod'; +import {NetworkService} from './app/model/network/network.service'; +import {ShareService} from './app/ui/gallery/share.service'; +import {UserService} from './app/model/network/user.service'; +import {AlbumsService} from './app/ui/albums/albums.service'; +import {GalleryCacheService} from './app/ui/gallery/cache.gallery.service'; +import {ContentService} from './app/ui/gallery/content.service'; +import {ContentLoaderService} from './app/ui/gallery/contentLoader.service'; +import {FilterService} from './app/ui/gallery/filter/filter.service'; +import {GallerySortingService} from './app/ui/gallery/navigator/sorting.service'; +import {GalleryNavigatorService} from './app/ui/gallery/navigator/navigator.service'; +import {MapService} from './app/ui/gallery/map/map.service'; +import {BlogService} from './app/ui/gallery/blog/blog.service'; +import {SearchQueryParserService} from './app/ui/gallery/search/search-query-parser.service'; +import {AutoCompleteService} from './app/ui/gallery/search/autocomplete.service'; +import {AuthenticationService} from './app/model/network/authentication.service'; +import {ThumbnailLoaderService} from './app/ui/gallery/thumbnailLoader.service'; +import {ThumbnailManagerService} from './app/ui/gallery/thumbnailManager.service'; +import {NotificationService} from './app/model/notification.service'; +import {FullScreenService} from './app/ui/gallery/fullscreen.service'; +import {NavigationService} from './app/model/navigation.service'; +import {SettingsService} from './app/ui/settings/settings.service'; +import {SeededRandomService} from './app/model/seededRandom.service'; +import {OverlayService} from './app/ui/gallery/overlay.service'; +import {QueryService} from './app/model/query.service'; +import {ThemeService} from './app/model/theme.service'; +import {DuplicateService} from './app/ui/duplicates/duplicates.service'; +import {FacesService} from './app/ui/faces/faces.service'; +import {VersionService} from './app/model/version.service'; +import {ScheduledJobsService} from './app/ui/settings/scheduled-jobs.service'; +import {BackendtextService} from './app/model/backendtext.service'; +import {CookieService} from 'ngx-cookie-service'; +import {GPXFilesFilterPipe} from './app/pipes/GPXFilesFilterPipe'; +import {MDFilesFilterPipe} from './app/pipes/MDFilesFilterPipe'; +import {FileSizePipe} from './app/pipes/FileSizePipe'; +import {DatePipe} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {provideAnimations} from '@angular/platform-browser/animations'; +import {AppRoutingModule} from './app/app.routing'; +import {NgIconsModule} from '@ng-icons/core'; +import { + ionAddOutline, + ionAlbumsOutline, + ionAppsOutline, + ionArrowDownOutline, + ionArrowUpOutline, + ionBrowsersOutline, + ionBrushOutline, + ionCalendarOutline, + ionCameraOutline, + ionChatboxOutline, + ionCheckmarkOutline, + ionChevronBackOutline, + ionChevronDownOutline, + ionChevronForwardOutline, + ionChevronUpOutline, + ionCloseOutline, + ionCloudOutline, + ionContractOutline, + ionCopyOutline, + ionDocumentOutline, + ionDocumentTextOutline, + ionDownloadOutline, + ionExpandOutline, + ionExtensionPuzzleOutline, + ionFileTrayFullOutline, + ionFlagOutline, + ionFolderOutline, + ionFunnelOutline, + ionGitBranchOutline, + ionGlobeOutline, + ionGridOutline, + ionHammerOutline, + ionImageOutline, + ionImagesOutline, + ionInformationCircleOutline, + ionInformationOutline, + ionLinkOutline, + ionList, + ionLocationOutline, + ionLockClosedOutline, + ionLogOutOutline, + ionMenuOutline, + ionMoonOutline, + ionOpenOutline, + ionPauseOutline, + ionPencil, + ionPeopleOutline, + ionPersonOutline, + ionPieChartOutline, + ionPlayOutline, + ionPricetagOutline, + ionPulseOutline, + ionRefresh, + ionRemoveOutline, + ionResizeOutline, + ionSaveOutline, + ionSearchOutline, + ionServerOutline, + ionSettingsOutline, + ionShareSocialOutline, + ionShuffleOutline, + ionSquareOutline, + ionStar, + ionStarOutline, + ionStopOutline, + ionSunnyOutline, + ionTextOutline, + ionTimeOutline, + ionTimerOutline, + ionTrashOutline, + ionUnlinkOutline, + ionVideocamOutline, + ionVolumeMediumOutline, + ionVolumeMuteOutline, + ionWarningOutline, + ionReload +} from '@ng-icons/ionicons'; +import {ClipboardModule} from 'ngx-clipboard'; +import {TooltipModule} from 'ngx-bootstrap/tooltip'; +import {ToastrModule} from 'ngx-toastr'; +import {ModalModule} from 'ngx-bootstrap/modal'; +import {CollapseModule} from 'ngx-bootstrap/collapse'; +import {PopoverModule} from 'ngx-bootstrap/popover'; +import {BsDropdownModule} from 'ngx-bootstrap/dropdown'; +import {BsDatepickerModule} from 'ngx-bootstrap/datepicker'; +import {TimepickerModule} from 'ngx-bootstrap/timepicker'; +import {LoadingBarModule} from '@ngx-loading-bar/core'; +import {LeafletModule} from '@bluehalo/ngx-leaflet'; +import {LeafletMarkerClusterModule} from '@bluehalo/ngx-leaflet-markercluster'; +import {MarkdownModule} from 'ngx-markdown'; +import {AppComponent} from './app/app.component'; import {Marker} from 'leaflet'; import {MarkerFactory} from './app/ui/gallery/map/MarkerFactory'; import {DurationPipe} from './app/pipes/DurationPipe'; @@ -100,77 +177,77 @@ export class CustomUrlSerializer implements UrlSerializer { Marker.prototype.options.icon = MarkerFactory.defIcon; bootstrapApplication(AppComponent, { - providers: [ - importProvidersFrom(BrowserModule, HammerModule, FormsModule, AppRoutingModule, NgIconsModule.withIcons({ - ionDownloadOutline, ionFunnelOutline, - ionGitBranchOutline, ionArrowDownOutline, ionArrowUpOutline, - ionStarOutline, ionStar, ionCalendarOutline, ionPersonOutline, ionShuffleOutline, - ionPeopleOutline, - ionMenuOutline, ionShareSocialOutline, - ionImagesOutline, ionLinkOutline, ionSearchOutline, ionHammerOutline, ionCopyOutline, - ionAlbumsOutline, ionSettingsOutline, ionLogOutOutline, - ionChevronForwardOutline, ionChevronDownOutline, ionChevronBackOutline, - ionTrashOutline, ionSaveOutline, ionAddOutline, ionRemoveOutline, - ionTextOutline, ionFolderOutline, ionDocumentOutline, ionDocumentTextOutline, ionImageOutline, - ionPricetagOutline, ionLocationOutline, - ionSunnyOutline, ionMoonOutline, ionVideocamOutline, - ionInformationCircleOutline, - ionInformationOutline, ionContractOutline, ionExpandOutline, ionCloseOutline, - ionTimerOutline, - ionPlayOutline, ionPauseOutline, ionVolumeMediumOutline, ionVolumeMuteOutline, - ionCameraOutline, ionWarningOutline, ionLockClosedOutline, ionChevronUpOutline, - ionFlagOutline, ionGlobeOutline, ionPieChartOutline, ionStopOutline, - ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, - ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, - ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, - ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList, ionPencil - }), ClipboardModule, TooltipModule.forRoot(), ToastrModule.forRoot(), - ModalModule.forRoot(), CollapseModule.forRoot(), PopoverModule.forRoot(), - BsDropdownModule.forRoot(), BsDatepickerModule.forRoot(), TimepickerModule.forRoot(), - LoadingBarModule, LeafletModule, LeafletMarkerClusterModule, - MarkdownModule.forRoot({ loader: HttpClient })), - { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, - { provide: UrlSerializer, useClass: CustomUrlSerializer }, - { provide: HAMMER_GESTURE_CONFIG, useClass: MyHammerConfig }, - StringifySortingMethod, - NetworkService, - ShareService, - UserService, - AlbumsService, - GalleryCacheService, - ContentService, - ContentLoaderService, - FilterService, - GallerySortingService, - GalleryNavigatorService, - MapService, - BlogService, - SearchQueryParserService, - AutoCompleteService, - AuthenticationService, - ThumbnailLoaderService, - ThumbnailManagerService, - NotificationService, - FullScreenService, - NavigationService, - SettingsService, - SeededRandomService, - OverlayService, - QueryService, - ThemeService, - DuplicateService, - FacesService, - VersionService, - ScheduledJobsService, - BackendtextService, - CookieService, - GPXFilesFilterPipe, - MDFilesFilterPipe, - FileSizePipe, - DatePipe, - DurationPipe, - provideHttpClient(withInterceptorsFromDi()), - provideAnimations() - ] + providers: [ + importProvidersFrom(BrowserModule, HammerModule, FormsModule, AppRoutingModule, NgIconsModule.withIcons({ + ionDownloadOutline, ionFunnelOutline, + ionGitBranchOutline, ionArrowDownOutline, ionArrowUpOutline, + ionStarOutline, ionStar, ionCalendarOutline, ionPersonOutline, ionShuffleOutline, + ionPeopleOutline, + ionMenuOutline, ionShareSocialOutline, + ionImagesOutline, ionLinkOutline, ionSearchOutline, ionHammerOutline, ionCopyOutline, + ionAlbumsOutline, ionSettingsOutline, ionLogOutOutline, + ionChevronForwardOutline, ionChevronDownOutline, ionChevronBackOutline, + ionTrashOutline, ionSaveOutline, ionAddOutline, ionRemoveOutline, + ionTextOutline, ionFolderOutline, ionDocumentOutline, ionDocumentTextOutline, ionImageOutline, + ionPricetagOutline, ionLocationOutline, + ionSunnyOutline, ionMoonOutline, ionVideocamOutline, + ionInformationCircleOutline, + ionInformationOutline, ionContractOutline, ionExpandOutline, ionCloseOutline, + ionTimerOutline, + ionPlayOutline, ionPauseOutline, ionVolumeMediumOutline, ionVolumeMuteOutline, + ionCameraOutline, ionWarningOutline, ionLockClosedOutline, ionChevronUpOutline, + ionFlagOutline, ionGlobeOutline, ionPieChartOutline, ionStopOutline, + ionTimeOutline, ionCheckmarkOutline, ionPulseOutline, ionResizeOutline, + ionCloudOutline, ionChatboxOutline, ionServerOutline, ionFileTrayFullOutline, ionBrushOutline, + ionBrowsersOutline, ionUnlinkOutline, ionSquareOutline, ionGridOutline, + ionAppsOutline, ionOpenOutline, ionRefresh, ionExtensionPuzzleOutline, ionList, ionPencil, ionReload + }), ClipboardModule, TooltipModule.forRoot(), ToastrModule.forRoot(), + ModalModule.forRoot(), CollapseModule.forRoot(), PopoverModule.forRoot(), + BsDropdownModule.forRoot(), BsDatepickerModule.forRoot(), TimepickerModule.forRoot(), + LoadingBarModule, LeafletModule, LeafletMarkerClusterModule, + MarkdownModule.forRoot({loader: HttpClient})), + {provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true}, + {provide: UrlSerializer, useClass: CustomUrlSerializer}, + {provide: HAMMER_GESTURE_CONFIG, useClass: MyHammerConfig}, + StringifySortingMethod, + NetworkService, + ShareService, + UserService, + AlbumsService, + GalleryCacheService, + ContentService, + ContentLoaderService, + FilterService, + GallerySortingService, + GalleryNavigatorService, + MapService, + BlogService, + SearchQueryParserService, + AutoCompleteService, + AuthenticationService, + ThumbnailLoaderService, + ThumbnailManagerService, + NotificationService, + FullScreenService, + NavigationService, + SettingsService, + SeededRandomService, + OverlayService, + QueryService, + ThemeService, + DuplicateService, + FacesService, + VersionService, + ScheduledJobsService, + BackendtextService, + CookieService, + GPXFilesFilterPipe, + MDFilesFilterPipe, + FileSizePipe, + DatePipe, + DurationPipe, + provideHttpClient(withInterceptorsFromDi()), + provideAnimations() + ] }) .catch((err) => console.error(err));