diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index b42a560e..9700cc1b 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -109,6 +109,11 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' + }, + { + id: 'config-plugins', + title: 'Plugins', + path: '/config/plugins' } ] diff --git a/client/pages/config/plugins/_slug.vue b/client/pages/config/plugins/_slug.vue new file mode 100644 index 00000000..619b6608 --- /dev/null +++ b/client/pages/config/plugins/_slug.vue @@ -0,0 +1,123 @@ + + + + + + arrow_back + + + + + + help_outline + + + + + + {{ configDescription }} + + + + + {{ field.label }} + + + + + + + + {{ $strings.ButtonSave }} + + + + + + + + diff --git a/client/pages/config/plugins/index.vue b/client/pages/config/plugins/index.vue new file mode 100644 index 00000000..d9244744 --- /dev/null +++ b/client/pages/config/plugins/index.vue @@ -0,0 +1,44 @@ + + + + + + + help_outline + + + + + Installed Plugins + + + {{ plugin.name }} + {{ plugin.description }} + + chevron_right + + + + + + + diff --git a/client/pages/login.vue b/client/pages/login.vue index df7ca109..3a2f165a 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -166,11 +166,11 @@ export default { location.reload() }, - setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, pluginExtensions }) { + setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices, plugins }) { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) - this.$store.commit('setPluginExtensions', pluginExtensions) + this.$store.commit('setPlugins', plugins) this.$setServerLanguageCode(serverSettings.language) if (serverSettings.chromecastEnabled) { diff --git a/client/store/index.js b/client/store/index.js index cdac75c2..dfbbc01c 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -29,7 +29,7 @@ export const state = () => ({ innerModalOpen: false, lastBookshelfScrollData: {}, routerBasePath: '/', - pluginExtensions: [] + plugins: [] }) export const getters = { @@ -64,9 +64,9 @@ export const getters = { return state.serverSettings.homeBookshelfView }, getPluginExtensions: (state) => (target) => { - return state.pluginExtensions + return state.plugins .map((pext) => { - const extensionsMatchingTarget = pext.extensions.filter((ext) => ext.target === target) + const extensionsMatchingTarget = pext.extensions?.filter((ext) => ext.target === target) || [] if (!extensionsMatchingTarget.length) return null return { name: pext.name, @@ -254,7 +254,7 @@ export const mutations = { setInnerModalOpen(state, val) { state.innerModalOpen = val }, - setPluginExtensions(state, val) { - state.pluginExtensions = val + setPlugins(state, val) { + state.plugins = val } } diff --git a/server/Auth.js b/server/Auth.js index 79237cfa..f3d2c38d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -939,7 +939,7 @@ class Auth { userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), serverSettings: Database.serverSettings.toJSONForBrowser(), ereaderDevices: Database.emailSettings.getEReaderDevices(user), - pluginExtensions: PluginManager.pluginExtensions, + plugins: PluginManager.pluginData, Source: global.Source } } diff --git a/server/controllers/PluginController.js b/server/controllers/PluginController.js index aed2f140..c69ecc0c 100644 --- a/server/controllers/PluginController.js +++ b/server/controllers/PluginController.js @@ -20,5 +20,19 @@ class PluginController { PluginManager.onAction(pluginSlug, actionName, target, data) res.sendStatus(200) } + + /** + * POST: /api/plugins/config + * + * @param {*} req + * @param {*} res + */ + handleConfigSave(req, res) { + const pluginSlug = req.body.pluginSlug + const config = req.body.config + Logger.info(`[PluginController] Saving config for plugin ${pluginSlug}`, config) + PluginManager.onConfigSave(pluginSlug, config) + res.sendStatus(200) + } } module.exports = new PluginController() diff --git a/server/managers/PluginManager.js b/server/managers/PluginManager.js index d7236299..93fd15c4 100644 --- a/server/managers/PluginManager.js +++ b/server/managers/PluginManager.js @@ -1,28 +1,31 @@ const Path = require('path') const Logger = require('../Logger') +const Database = require('../Database') const PluginAbstract = require('../PluginAbstract') const fs = require('fs').promises +/** + * @typedef PluginContext + * @property {import('../../server/Logger')} Logger + * @property {import('../../server/Database')} Database + */ + class PluginManager { constructor() { this.plugins = [] } - get pluginExtensions() { - return this.plugins - .filter((plugin) => plugin.manifest.extensions?.length) - .map((plugin) => { - return { - name: plugin.manifest.name, - slug: plugin.manifest.slug, - extensions: plugin.manifest.extensions - } - }) + get pluginData() { + return this.plugins.map((plugin) => plugin.manifest) } + /** + * @returns {PluginContext} + */ get pluginContext() { return { - Logger + Logger, + Database } } @@ -59,23 +62,27 @@ class PluginManager { // TODO: Validate manifest json - let pluginContents = null + let pluginInstance = null try { - pluginContents = require(Path.join(pluginPath, indexFile.name)) + pluginInstance = require(Path.join(pluginPath, indexFile.name)) } catch (error) { Logger.error(`Error loading plugin ${pluginPath}`, error) return null } + if (typeof pluginInstance.init !== 'function') { + Logger.error(`Plugin ${pluginPath} does not have an init function`) + return null + } + return { manifest: manifestJson, - contents: pluginContents + instance: pluginInstance } } async loadPlugins() { const pluginDirs = await fs.readdir(global.PluginsPath, { withFileTypes: true, recursive: true }).then((files) => files.filter((file) => file.isDirectory())) - console.log('pluginDirs', pluginDirs) for (const pluginDir of pluginDirs) { Logger.info(`[PluginManager] Loading plugin ${pluginDir.name}`) @@ -91,9 +98,9 @@ class PluginManager { await this.loadPlugins() for (const plugin of this.plugins) { - if (plugin.contents.init) { + if (plugin.instance.init) { Logger.info(`[PluginManager] Initializing plugin ${plugin.manifest.name}`) - plugin.contents.init(this.pluginContext) + plugin.instance.init(this.pluginContext) } } } @@ -111,9 +118,22 @@ class PluginManager { return } - if (plugin.contents.onAction) { + if (plugin.instance.onAction) { Logger.info(`[PluginManager] Calling onAction for plugin ${plugin.manifest.name}`) - plugin.contents.onAction(this.pluginContext, actionName, target, data) + plugin.instance.onAction(this.pluginContext, actionName, target, data) + } + } + + onConfigSave(pluginSlug, config) { + const plugin = this.plugins.find((plugin) => plugin.manifest.slug === pluginSlug) + if (!plugin) { + Logger.error(`[PluginManager] Plugin ${pluginSlug} not found`) + return + } + + if (plugin.instance.onConfigSave) { + Logger.info(`[PluginManager] Calling onConfigSave for plugin ${plugin.manifest.name}`) + plugin.instance.onConfigSave(this.pluginContext, config) } } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 5d4187b8..beb3db1f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -325,6 +325,7 @@ class ApiRouter { // Plugin routes // this.router.post('/plugins/action', PluginController.handleAction.bind(this)) + this.router.post('/plugins/config', PluginController.handleConfigSave.bind(this)) // // Misc Routes diff --git a/test/server/managers/plugins/Example/index.js b/test/server/managers/plugins/Example/index.js index 0e938fe5..6f3e5603 100644 --- a/test/server/managers/plugins/Example/index.js +++ b/test/server/managers/plugins/Example/index.js @@ -1,18 +1,54 @@ -const PluginAbstract = require('../../../../../server/PluginAbstract') - -class ExamplePlugin extends PluginAbstract { +class ExamplePlugin { constructor() { - super() - this.name = 'Example' } - init(context) { + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + */ + async init(context) { context.Logger.info('[ExamplePlugin] Example plugin loaded successfully') + + context.Database.mediaProgressModel.addHook('afterSave', (instance, options) => { + context.Logger.debug(`[ExamplePlugin] mediaProgressModel afterSave hook for mediaProgress ${instance.id}`) + this.handleMediaProgressUpdate(context, instance) + }) } + /** + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {import('../../server/models/MediaProgress')} mediaProgress + */ + async handleMediaProgressUpdate(context, mediaProgress) { + const mediaItem = await mediaProgress.getMediaItem() + if (!mediaItem) { + context.Logger.error(`[ExamplePlugin] Media item not found for mediaProgress ${mediaProgress.id}`) + } else { + const mediaProgressDuration = mediaProgress.duration + const progressPercent = mediaProgressDuration > 0 ? (mediaProgress.currentTime / mediaProgressDuration) * 100 : 0 + context.Logger.info(`[ExamplePlugin] Media progress update for "${mediaItem.title}" ${Math.round(progressPercent)}%`) + } + } + + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {string} actionName + * @param {string} target + * @param {*} data + */ async onAction(context, actionName, target, data) { context.Logger.info('[ExamplePlugin] Example plugin onAction', actionName, target, data) } + + /** + * + * @param {import('../../server/managers/PluginManager').PluginContext} context + * @param {*} config + */ + async onConfigSave(context, config) { + context.Logger.info('[ExamplePlugin] Example plugin onConfigSave', config) + } } module.exports = new ExamplePlugin() diff --git a/test/server/managers/plugins/Example/manifest.json b/test/server/managers/plugins/Example/manifest.json index 96a31e4d..33b25d24 100644 --- a/test/server/managers/plugins/Example/manifest.json +++ b/test/server/managers/plugins/Example/manifest.json @@ -12,8 +12,6 @@ } ], "config": { - "title": "Example Plugin Configuration", - "titleKey": "ExamplePluginConfiguration", "description": "This is an example plugin", "descriptionKey": "ExamplePluginConfigurationDescription", "formFields": [ @@ -44,7 +42,6 @@ "ItemExampleAction": "Item Example Action", "LabelApiKey": "API Key", "LabelEnable": "Enable", - "ExamplePluginConfiguration": "Example Plugin Configuration", "ExamplePluginConfigurationDescription": "This is an example plugin", "LabelRequestAddress": "Request Address" }
{{ configDescription }}
{{ plugin.name }}
{{ plugin.description }}