Update example plugin and add plugins frontend page with save config endpoint

This commit is contained in:
advplyr 2024-12-20 17:21:00 -06:00
parent 62bd7e73f4
commit ad89fb2eac
11 changed files with 276 additions and 36 deletions

View File

@ -109,6 +109,11 @@ export default {
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
},
{
id: 'config-plugins',
title: 'Plugins',
path: '/config/plugins'
}
]

View File

@ -0,0 +1,123 @@
<template>
<div>
<app-settings-content :header-text="`Plugin: ${plugin.name}`">
<template #header-prefix>
<nuxt-link to="/config/plugins" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
<span class="material-symbols text-2xl">arrow_back</span>
</nuxt-link>
</template>
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</template>
<div class="py-4">
<p class="mb-4">{{ configDescription }}</p>
<form v-if="configFormFields.length" @submit.prevent="handleFormSubmit">
<template v-for="field in configFormFields">
<div :key="field.name" class="flex items-center mb-4">
<label :for="field.name" class="w-1/3 text-gray-200">{{ field.label }}</label>
<div class="w-2/3">
<input :id="field.name" :type="field.type" :placeholder="field.placeholder" class="w-full bg-bg border border-white border-opacity-20 rounded-md p-2 text-gray-200" />
</div>
</div>
</template>
<div class="flex justify-end">
<ui-btn class="bg-primary bg-opacity-70 text-white rounded-md p-2" :loading="processing" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ store, redirect, params }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
const plugin = store.state.plugins.find((plugin) => plugin.slug === params.slug)
if (!plugin) {
redirect('/config/plugins')
}
return {
plugin
}
},
data() {
return {
processing: false
}
},
computed: {
pluginConfig() {
return this.plugin.config
},
pluginLocalization() {
return this.plugin.localization || {}
},
localizedStrings() {
const localeKey = this.$languageCodes.current
if (!localeKey) return {}
return this.pluginLocalization[localeKey] || {}
},
configDescription() {
if (this.pluginConfig.descriptionKey && this.localizedStrings[this.pluginConfig.descriptionKey]) {
return this.localizedStrings[this.pluginConfig.descriptionKey]
}
return this.pluginConfig.description
},
configFormFields() {
return this.pluginConfig.formFields || []
}
},
methods: {
getFormData() {
const formData = {}
this.configFormFields.forEach((field) => {
if (field.type === 'checkbox') {
formData[field.name] = document.getElementById(field.name).checked
} else {
formData[field.name] = document.getElementById(field.name).value
}
})
return formData
},
handleFormSubmit() {
const formData = this.getFormData()
console.log('Form data', formData)
const payload = {
pluginSlug: this.plugin.slug,
config: formData
}
this.processing = true
this.$axios
.$post(`/api/plugins/config`, payload)
.then(() => {
console.log('Plugin configuration saved')
})
.catch((error) => {
console.error('Error saving plugin configuration', error)
this.$toast.error('Error saving plugin configuration')
})
.finally(() => {
this.processing = false
})
}
},
mounted() {
console.log('Plugin', this.plugin)
},
beforeDestroy() {}
}
</script>

View File

@ -0,0 +1,44 @@
<template>
<div>
<app-settings-content :header-text="'Plugins'">
<template #header-items>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</template>
<h2 class="text-xl font-medium">Installed Plugins</h2>
<template v-for="plugin in plugins">
<nuxt-link :key="plugin.slug" :to="`/config/plugins/${plugin.slug}`" class="flex items-center bg-primary rounded-md shadow-sm p-4 my-4 space-x-4">
<p class="text-lg">{{ plugin.name }}</p>
<p class="text-sm text-gray-300">{{ plugin.description }}</p>
<div class="flex-grow" />
<span class="material-symbols text-4xl">chevron_right</span>
</nuxt-link>
</template>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {}
},
computed: {
plugins() {
return this.$store.state.plugins
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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