diff --git a/docs/plugins.md b/docs/plugins.md index d63b86aef8..8d31817656 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,6 +9,7 @@ The plugin system is based on the Symfony Dependency Injection component. Plugins are installed in the `content/plugins` directory of your phpMyFAQ installation. The plugin directory should contain a subdirectory for each plugin, e.g. `content/plugins/HelloWorld`. The plugin directory should contain a `HelloWorldPlugin.php` file that implements the `PluginInterface` interface. +The plugin must then be activated in the backend and can be deactivated again at any time without a complete uninstallation. If you want to remove a plugin, you can delete the plugin in the plugin directory. ## 9.2 Configuration @@ -17,7 +18,7 @@ Plugins can have configuration options, implemented via the `PluginConfiguration Configuration options can be defined in the plugin configuration class with Constructor Property Promotion by adding public properties. There are no other options to configure plugins at this time. -### 9.3.1 Example configuration class +### 9.2.1 Example configuration class ```php class MyPluginConfiguration implements PluginConfigurationInterface @@ -29,6 +30,7 @@ class MyPluginConfiguration implements PluginConfigurationInterface } } ``` +These predefined values in the file form the basis for the configuration options in the backend plugin overview. All future changes to these values in the backend plugin settings will be saved in the database. ## 9.3 Plugin development @@ -79,6 +81,28 @@ class MyPlugin implements PluginInterface return '0.2.0'; } + public function getDescription(): string + { + return 'A simple Hello World plugin'; + } + + public function getAuthor(): string + { + return 'phpMyFAQ Team'; + } + + // Extended description of the plugin that will be displayed in the plugin settings + public function getAdvDescription(): string + { + return 'A simple Hello World plugin that demonstrates event handling in phpMyFAQ and with Configuration options.'; + } + + // Implementation of the plugin that will be displayed in the plugin settings + public function getImplementation(): string + { + return '{{ phpMyFAQPlugin(\'hello.world\', \'Hello, World!\') | raw }} oder {{ phpMyFAQPlugin(\'user.login\', \'John Doe\') | raw }}'; + } + public function getDependencies(): array { return []; @@ -139,6 +163,7 @@ class MyPlugin implements PluginInterface {{ phpMyFAQPlugin('user.login', 'John Doe') | raw }} ``` +Note: If the fields "Extended Description" and "Implementation" are defined in the plugin, you will find explanations on how to integrate the plugin in the plugin's configuration settings. ## 9.6 Stylesheets diff --git a/phpmyfaq/admin/assets/src/api/index.ts b/phpmyfaq/admin/assets/src/api/index.ts index d7e8270a1e..3daaa1da36 100644 --- a/phpmyfaq/admin/assets/src/api/index.ts +++ b/phpmyfaq/admin/assets/src/api/index.ts @@ -12,6 +12,7 @@ export * from './markdown'; export * from './media-browser'; export * from './news'; export * from './opensearch'; +export * from './plugins'; export * from './question'; export * from './statistics'; export * from './stop-words'; diff --git a/phpmyfaq/admin/assets/src/api/plugins.ts b/phpmyfaq/admin/assets/src/api/plugins.ts new file mode 100644 index 0000000000..1e69c31d73 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/plugins.ts @@ -0,0 +1,77 @@ +/** + * Plugin API calls + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2025-2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2025-01-07 + */ + +import { Response } from '../interfaces'; + +/** + * Toggle plugin status + * + * @param name + * @param active + * @param csrfToken + */ +export const togglePluginStatus = async (name: string, active: boolean, csrfToken: string): Promise => { + const response = await fetch('api/plugin/toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ + name, + active, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP error! status: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as Response; +}; + +/** + * Save plugin configuration + * + * @param name + * @param config + * @param csrfToken + */ +export const savePluginConfig = async ( + name: string, + config: Record, + csrfToken: string +): Promise => { + const response = await fetch('api/plugin/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ + name, + config, + csrf: csrfToken, // Also including in body as requested for backend validation + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP error! status: ${response.status} ${response.statusText}`); + } + + return (await response.json()) as Response; +}; diff --git a/phpmyfaq/admin/assets/src/configuration/index.ts b/phpmyfaq/admin/assets/src/configuration/index.ts index f3113ba55a..d1cdc81019 100644 --- a/phpmyfaq/admin/assets/src/configuration/index.ts +++ b/phpmyfaq/admin/assets/src/configuration/index.ts @@ -3,5 +3,6 @@ export * from './elasticsearch'; export * from './forms'; export * from './instance'; export * from './opensearch'; +export * from './plugins'; export * from './stopwords'; export * from './upgrade'; diff --git a/phpmyfaq/admin/assets/src/configuration/plugins.ts b/phpmyfaq/admin/assets/src/configuration/plugins.ts new file mode 100644 index 0000000000..a03c150ccb --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/plugins.ts @@ -0,0 +1,227 @@ +/** + * Plugin management logic for phpMyFAQ admin backend + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2025-2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2025-01-07 + */ + +import { togglePluginStatus, savePluginConfig } from '../api'; +import { addElement, pushNotification, pushErrorNotification, TranslationService } from '../../../../assets/src/utils'; + +/** + * Handles plugin status toggling and configuration modal + */ +export const handlePlugins = async (): Promise => { + const Translator = new TranslationService(); + await Translator.loadTranslations(document.documentElement.lang); + + const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + + const toggleCheckboxes = document.querySelectorAll('.plugin-toggle'); + toggleCheckboxes.forEach((checkbox) => { + checkbox.addEventListener('change', async (event) => { + const target = event.target as HTMLInputElement; + const row = target.closest('tr'); + const pluginName = row ? row.getAttribute('data-plugin-name') : null; + const active = target.checked; + const csrfToken = getCsrfToken(); + + if (!pluginName) { + return; + } + + try { + const result = await togglePluginStatus(pluginName, active, csrfToken); + + if (!result.success) { + target.checked = !active; // Revert + pushErrorNotification( + Translator.translate('msgPluginStatusError') + ' ' + (result.message || Translator.translate('msgUnknownError')), + ); + } else { + pushNotification(Translator.translate('msgPluginStatusSuccess')); + } + } catch (error: any) { + target.checked = !active; // Revert + console.error('Error toggling plugin:', error); + pushErrorNotification(error.message || Translator.translate('msgUnknownError')); + } + }); + }); + + // Configuration Modal + const pluginConfigModal = document.getElementById('pluginConfigModal'); + if (pluginConfigModal) { + pluginConfigModal.addEventListener('show.bs.modal', (event: any) => { + const button = event.relatedTarget as HTMLElement; + const pluginName = button.getAttribute('data-plugin-name'); + const pluginDescription = button.getAttribute('data-plugin-description'); + const pluginImplementation = button.getAttribute('data-plugin-implementation'); + const configJson = button.getAttribute('data-plugin-config'); + + const modalTitle = pluginConfigModal.querySelector('.modal-title'); + const nameInput = pluginConfigModal.querySelector('#configPluginName'); + const container = pluginConfigModal.querySelector('#configFieldsContainer'); + const descText = pluginConfigModal.querySelector('#pluginDescriptionText'); + const implContainer = pluginConfigModal.querySelector('#pluginImplementationContainer'); + const implCode = pluginConfigModal.querySelector('#pluginImplementationCode'); + const noConfigMsg = pluginConfigModal.querySelector('#pluginNoConfigMsg'); + const saveBtn = document.getElementById('savePluginConfigBtn'); + + if (modalTitle && pluginName) { + modalTitle.textContent = Translator.translate('msgConfig') + ': ' + pluginName; + } + if (nameInput && pluginName) { + nameInput.value = pluginName; + } + if (descText) { + descText.textContent = pluginDescription || '-'; + } + + if (implContainer && implCode) { + if (pluginImplementation) { + implCode.textContent = pluginImplementation; + implContainer.classList.remove('d-none'); + } else { + implContainer.classList.add('d-none'); + } + } + + if (container) { + container.innerHTML = ''; // Clear previous fields + } + if (noConfigMsg) noConfigMsg.classList.add('d-none'); + if (saveBtn) saveBtn.style.display = 'none'; + + let hasConfig = false; + + if (configJson) { + try { + const configData = JSON.parse(configJson); + + if (configData && typeof configData === 'object' && Object.keys(configData).length > 0) { + hasConfig = true; + if (saveBtn) saveBtn.style.display = 'block'; + + Object.keys(configData).forEach((key) => { + const value = configData[key]; + let input: HTMLElement; + + if (typeof value === 'boolean') { + input = addElement('div', { className: 'form-check form-switch mb-3' }, [ + addElement('input', { + type: 'checkbox', + className: 'form-check-input', + checked: value, + value: '1', + id: 'config_' + key, + name: 'config[' + key + ']', + }), + addElement('label', { + className: 'form-check-label', + textContent: key, + htmlFor: 'config_' + key, + }), + ]); + } else { + const type = (typeof value === 'number' || !isNaN(Number(value)) && String(value).trim() !== '') ? 'number' : + (key.toLowerCase().includes('email')) ? 'email' : + (key.toLowerCase().includes('date')) ? 'date' : 'text'; + + const props: Record = { + className: 'form-control', + value: String(value), + id: 'config_' + key, + name: 'config[' + key + ']', + }; + + if (String(value).length > 50 && type === 'text') { + input = addElement('div', { className: 'mb-3' }, [ + addElement('label', { className: 'form-label', textContent: key, htmlFor: 'config_' + key }), + addElement('textarea', { ...props, rows: 3 }), + ]); + } else { + input = addElement('div', { className: 'mb-3' }, [ + addElement('label', { className: 'form-label', textContent: key, htmlFor: 'config_' + key }), + addElement('input', { ...props, type }), + ]); + } + } + + if (container) { + container.appendChild(input); + } + }); + } + } catch (e) { + console.error('Error parsing config:', e); + } + } + + if (!hasConfig && noConfigMsg) { + noConfigMsg.classList.remove('d-none'); + } + }); + } + + // Save Configuration + const saveBtn = document.getElementById('savePluginConfigBtn'); + if (saveBtn) { + saveBtn.addEventListener('click', async () => { + const form = document.getElementById('pluginConfigForm') as HTMLFormElement; + const container = document.getElementById('configFieldsContainer'); + if (!form || !container) return; + + const nameInput = form.querySelector('input[name="name"]'); + const pluginName = nameInput?.value; + const csrfToken = getCsrfToken(); + const configData: Record = {}; + + const inputs = container.querySelectorAll( + 'input, select, textarea', + ); + inputs.forEach((input) => { + const nameAttr = input.getAttribute('name'); + if (nameAttr && nameAttr.startsWith('config[')) { + const key = nameAttr.substring(7, nameAttr.length - 1); + if (input instanceof HTMLInputElement && input.type === 'checkbox') { + configData[key] = input.checked; + } else if (input instanceof HTMLInputElement && input.type === 'number') { + configData[key] = input.value.includes('.') ? parseFloat(input.value) : parseInt(input.value, 10); + if (isNaN(configData[key])) { + configData[key] = 0; + } + } else { + configData[key] = input.value; + } + } + }); + + if (!pluginName) return; + + try { + const result = await savePluginConfig(pluginName, configData, csrfToken); + + if (result.success) { + pushNotification(Translator.translate('msgPluginConfigSuccess')); + window.location.reload(); + } else { + pushErrorNotification( + Translator.translate('msgPluginConfigError') + ' ' + (result.message || Translator.translate('msgUnknownError')), + ); + } + } catch (error: any) { + console.error('Error saving config:', error); + pushErrorNotification(error.message || Translator.translate('msgUnknownError')); + } + }); + } +}; diff --git a/phpmyfaq/admin/assets/src/index.ts b/phpmyfaq/admin/assets/src/index.ts index 8f80fc51ce..78429e55e3 100644 --- a/phpmyfaq/admin/assets/src/index.ts +++ b/phpmyfaq/admin/assets/src/index.ts @@ -36,6 +36,7 @@ import { handleFormEdit, handleFormTranslations, handleOpenSearch, + handlePlugins, } from './configuration'; import { handleAttachmentUploads, @@ -167,6 +168,9 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { await handleElasticsearch(); await handleOpenSearch(); + // Configuration → Plugins + handlePlugins(); + // Import & Export → Import Records await handleUploadCSVForm(); diff --git a/phpmyfaq/assets/templates/admin/configuration/plugins.twig b/phpmyfaq/assets/templates/admin/configuration/plugins.twig index 554a50b254..e5953eb594 100644 --- a/phpmyfaq/assets/templates/admin/configuration/plugins.twig +++ b/phpmyfaq/assets/templates/admin/configuration/plugins.twig @@ -20,22 +20,92 @@ {{ 'msgPluginName' | translate }} {{ 'msgPluginVersion' | translate }} + {{ 'msgActive' | translate }} {{ 'msgPluginAuthor' | translate }} {{ 'msgPluginDescription' | translate }} + {{ 'msgActions' | translate }} {% for plugin in pluginList %} - + {{ plugin.name }} {{ plugin.version }} + + {% set safePluginName = plugin.name | replace({'.': '_', ' ': '_'}) %} +
+ + +
+ {{ plugin.author }} {{ plugin.description }} + + + {% endfor %} + + + + {% if incompatiblePlugins is not empty %}