Differences
This shows you the differences between two versions of the page.
| Both sides previous revision Previous revision Next revision | Previous revision | ||
| en:entwickler:plugin_manager [2026/02/08 12:03] – [PluginAbstract class] mightymcoder | en:entwickler:plugin_manager [2026/10/23 23:22] (current) – kainhofer | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| ========= Plugin Manager ========= | ========= Plugin Manager ========= | ||
| - | {{ : | ||
| - | The Plugin Manager module provides a simple user interface and standardized procedures for | ||
| - | * installing, | ||
| - | * uninstalling, | ||
| - | * updating, | ||
| - | * managing and | ||
| - | * integrating | ||
| - | plugins in Admidio. | ||
| - | With the Plugin Manager | + | The Plugin Manager is the part of Admidio that knows which plugins |
| + | and what has to happen to move them from one state to the next. It provides | ||
| - | A Basic //" | + | * a **plugin administration** page under //Administration → Extensions// for installing, enabling, disabling, updating and removing plugins, |
| - | ======== Make your plugin | + | * a **plugin |
| - | To make your plugin | + | * an **upload** for a plugin that arrives as a ZIP archive, |
| - | * [[en: | + | * a **runtime** that loads the enabled plugins into every request, and |
| - | | + | * a set of **admidio plugin:*** command line tasks that do the same as the web interface. |
| - | | + | |
| - | * A [[en: | + | |
| - | * A [[en: | + | |
| - | ======= Naming conventions ======= | + | <WRAP center round important 80%> |
| - | When using the new Plugin Manager, the following naming conventions must be observed: | + | This page describes |
| - | - For better readability file and folder names containing multiple words should follow | + | that was introduced for Admidio 5.0 and is not compatible with it — see |
| - | - All folder | + | [[en: |
| - | - No plugin file defining | + | </ |
| - | ======= Folder structure ======= | + | |
| - | The basic folder structure | + | |
| - | {{:en: | + | |
| - | ====== classes folder ====== | + | A complete, commented example |
| - | ===== Main plugin | + | **plugins/hello-world/**. It shows every convention |
| - | The main [[en: | + | and its own ``README.md`` is the short form of this page. Copy that directory, rename it, and you |
| + | have a working plugin. | ||
| - | ===== Presenter classes | + | ======= |
| - | ==== Plugin-specific classes ==== | + | |
| - | Inside the // | + | |
| - | ==== Plugin preferences class ==== | + | A plugin |
| - | === Single FormPresenter element === | + | file **plugin.php**. Nothing else is required. A plugin that only registers |
| - | If the plugin | + | two files: no class, no base class, no interface, no settings, no page and no language file. |
| - | | + | The **directory name is the plugin ID** and the only identity a plugin ever has. It is used in |
| + | preference names, in URLs and in the components table, so it must consist of lowercase letters, | ||
| + | digits and single hyphens (e.g. ``hello-world``). The display name may change, be translated or be | ||
| + | duplicated by another plugin without any consequence. | ||
| - | Below is an example snippet of the // | + | ====== Folder structure ====== |
| - | <code php> | + | |
| - | public static function createBirthdayForm(Smarty $smarty): string | + | |
| - | { | + | |
| - | global $gL10n, $gCurrentSession; | + | |
| - | $pluginBirthday = Birthday:: | + | <code text> |
| - | | + | plugins/ |
| + | ├── plugin.json | ||
| + | ├── plugin.php | ||
| + | ├── cli.php | ||
| + | ├── modules/ | ||
| + | ├── src/ | ||
| + | ├── languages/ | ||
| + | ├── templates/ | ||
| + | ├── assets/ | ||
| + | ├── db_scripts/ | ||
| + | └── tests/ | ||
| + | </ | ||
| - | $formBirthday = new FormPresenter( | + | None of the subdirectories has to exist and **none of them has to be declared** in the manifest. |
| - | ' | + | They are conventions the core looks for. |
| - | $pluginBirthday:: | + | |
| - | | + | |
| - | null, | + | |
| - | array(' | + | |
| - | ); | + | |
| - | $selectBoxEntries | + | ====== Naming conventions ====== |
| - | ' | + | |
| - | ' | + | |
| - | ' | + | |
| - | ); | + | |
| - | $formBirthday-> | + | |
| - | ' | + | - Class files below the autoload directory |
| - | Language:: | + | - The plugin ID — the directory name — is lowercase letters, digits and single hyphens. |
| - | | + | |
| - | array( | + | |
| - | ' | + | |
| - | ' | + | |
| - | ' | + | |
| - | ) | + | |
| - | ); | + | |
| - | $formBirthday-> | + | ======= The manifest ======= |
| - | ' | + | |
| - | Language:: | + | |
| - | $formValues[' | + | |
| - | array(' | + | |
| - | ); | + | |
| - | [...] | + | Each plugin has to provide a JSON manifest **plugin.json**. Only ``name`` and ``version`` really |
| + | matter to the core; everything else is optional. A manifest that cannot be read, is not valid JSON | ||
| + | or declares no version makes the plugin **broken**: it is still listed and can still be removed, but | ||
| + | it is never loaded and cannot be installed. | ||
| - | $formBirthday-> | + | ^ Key ^ Mandatory ^ Description ^ |
| - | ' | + | | ``name`` | X | Display name. May be a language string ID such as ``PLG_BIRTHDAY_PLUGIN_NAME``. | |
| - | | + | | ``version`` | X | Version of the files. Compared against the installed version to detect an update. | |
| - | | + | | ``description`` | | Short description shown in the plugin administration. May be a language string ID. | |
| - | ); | + | | ``author`` | | The author of the plugin. | |
| + | | ``url`` | | A URL to a wiki page or a project page. | | ||
| + | | ``icon`` | | Bootstrap icon name, e.g. ``bi-cake2``. | | ||
| + | | ``menu`` | | ``false`` suppresses the menu entry below // | ||
| + | | ``requires`` | | Version constraints and dependencies, | ||
| + | | ``autoload`` | | PSR-4 mappings, as namespace prefix | ||
| + | | ``preferences`` | | Where the settings panel of the plugin appears: ``section`` and ``sequence``. | | ||
| + | | ``settings`` | | The preferences the plugin owns. | | ||
| + | |||
| + | A manifest may carry further keys of its own; they are kept and are readable through | ||
| + | ``$plugin-> | ||
| - | $formBirthday->addToSmarty($smarty); | + | ====== requires ====== |
| - | | + | |
| - | | + | <code json> |
| + | " | ||
| + | | ||
| + | | ||
| + | " | ||
| + | " | ||
| + | " | ||
| } | } | ||
| </ | </ | ||
| + | A constraint is a **space separated list of terms that all have to match**, e.g. ``>=5.1 <6.0``. | ||
| + | A term is an operator (``>=``, ``>``, ``<=``, ``<``, ``=``, ``!=``) followed by a version; a term | ||
| + | without an operator means ``>=``. | ||
| - | === Multiple FormPresenter elements === | + | ``admidio``, |
| - | With Admidio 5 the preference page has been redesigned. It is now possible to display multiple " | + | plugin broken, so it cannot |
| - | In your PluginPreferencePresenter class you can now define multiple FormPresenter elements and add different preferences | + | ``modules`` is deliberately **not** a requirement. A switched-off Admidio module is neither the |
| + | plugin' | ||
| + | switched on again. The plugin administration therefore tells the administrator about it instead of | ||
| + | refusing | ||
| + | ``photos``, ``messages``), | ||
| - | < | + | ====== autoload ====== |
| - | public static function createBirthdayForm(Smarty $smarty): string | + | |
| + | < | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | The prefix must **not** start with ``Admidio\``, | ||
| + | shadowed, and the directory has to stay inside the plugin directory. Both are verified when the | ||
| + | manifest is read; a violation makes the plugin broken. | ||
| + | |||
| + | ====== preferences ====== | ||
| + | |||
| + | <code json> | ||
| + | " | ||
| + | </ | ||
| + | |||
| + | ``section`` names one of the preference tabs and ``sequence`` places the panel inside it. The tabs | ||
| + | are ``system``, ``login_security``, | ||
| + | ``overview_extensions`` and ``extensions``. A plugin is not restricted to the last two: a module that | ||
| + | used to be part of the Admidio core keeps its own tab once it becomes a plugin. A plugin that names | ||
| + | no section, or one that no longer exists, lands in ``extensions``. | ||
| + | |||
| + | ====== settings ====== | ||
| + | |||
| + | The ``settings`` object declares the preferences the plugin owns. They become **ordinary Admidio | ||
| + | preferences** when the plugin is loaded, get a row in every organization when it is installed and are | ||
| + | removed again when it is uninstalled. | ||
| + | |||
| + | Each setting declares a ``type`` | ||
| + | ``default``, | ||
| + | Admidio to **build the settings form by itself**, so a plugin only writes a preferences presenter | ||
| + | when it wants more than the manifest can express. | ||
| + | |||
| + | A setting **without** a ``label`` is not put into that form. A preference the plugin owns is not | ||
| + | necessarily one this form edits — the position of an overview widget is edited in the overview | ||
| + | preferences, | ||
| + | |||
| + | An ``enum`` lists its permitted values, and may instead name them so that the generated form has | ||
| + | something readable to show: | ||
| + | |||
| + | <code json> | ||
| + | " | ||
| + | " | ||
| + | </ | ||
| + | |||
| + | An ``integer`` may bound what the generated form offers with ``min``, ``max`` and ``step``. The | ||
| + | value is stored as an integer either way; a bound only restricts the input field. | ||
| + | |||
| + | <code json> | ||
| + | " | ||
| + | </ | ||
| + | |||
| + | Three preference names are conventions the core understands: | ||
| + | |||
| + | ^ Preference ^ Meaning ^ | ||
| + | | ``plugin_< | ||
| + | | ``< | ||
| + | | ``< | ||
| + | |||
| + | In ``< | ||
| + | |||
| + | ====== A complete manifest ====== | ||
| + | |||
| + | <code json> | ||
| { | { | ||
| - | | + | |
| - | + | | |
| - | | + | |
| - | ' | + | " |
| - | | + | " |
| - | | + | " |
| - | null, | + | " |
| - | | + | |
| - | | + | " |
| - | | + | }, |
| - | | + | " |
| - | ' | + | |
| - | ' | + | |
| - | ); | + | |
| - | $formBirthday-> | + | |
| - | | + | " |
| - | | + | " |
| - | | + | " |
| - | | + | " |
| - | ); | + | |
| - | + | | |
| - | [...] | + | " |
| - | + | " | |
| - | $formBirthday-> | + | " |
| - | | + | " |
| - | | + | " |
| - | | + | " |
| - | ); | + | }, |
| - | + | " | |
| - | | + | " |
| - | | + | |
| - | + | | |
| - | [...] | + | " |
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| + | | ||
| + | } | ||
| </ | </ | ||
| - | Now you can define a new FormPresenter for the second " | + | ======= The entry file ======= |
| + | |||
| + | **plugin.php** registers what the plugin contributes and returns nothing. There is **no plugin | ||
| + | class, no interface and no base class**. It runs inside the fully initialized Admidio request, so it | ||
| + | can call any public Admidio API — normally it registers hooks: | ||
| <code php> | <code php> | ||
| - | $formBirthday2= new FormPresenter( | + | <?php |
| - | ' | + | use Admidio\Hooks\Hooks; |
| - | | + | use AdmidioPlugin\HelloWorld\Greeting; |
| - | | + | |
| - | | + | /* |
| - | array('class' => ' | + | * This file is included by the plugin |
| - | | + | * Admidio bootstrap it could do nothing anyway; the guard only turns a PHP error into a message. |
| - | + | */ | |
| - | [...] | + | if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '' |
| - | + | exit('This page may not be called directly!'); | |
| - | $formBirthday2-> | + | } |
| - | | + | |
| - | $gL10n-> | + | Hooks:: |
| - | array(' | + | |
| - | | + | |
| - | + | ||
| - | [...] | + | |
| </ | </ | ||
| - | This form cannot be added to smarty with the // | + | A plugin with more to announce puts that into a class of its own and calls it from the entry file, |
| + | which is what the built-in plugins do: | ||
| <code php> | <code php> | ||
| - | [...] | + | use AdmidioPlugin\Birthday\Birthday; |
| - | + | ||
| - | | + | Birthday:: |
| - | $gCurrentSession-> | + | |
| - | + | ||
| - | [..] | + | |
| </ | </ | ||
| - | You can add as many forms as you like using this method. | + | The entry file is included in a scope of its own, so a variable it assigns cannot collide with |
| + | anything in the loader. Top-level code should stay cheap: it runs in **every request** of every | ||
| + | organization the plugin is enabled for. | ||
| - | When all forms are added to smarty you can use a " | + | ====== What loading a plugin means ====== |
| + | |||
| + | Loading one plugin means four things and nothing else: | ||
| + | |||
| + | - its ``autoload`` mappings become resolvable classes, | ||
| + | - its **languages/ | ||
| + | - its ``settings`` and the preference that enables it are registered as ordinary Admidio preferences, | ||
| + | - its **plugin.php** is included once. | ||
| + | |||
| + | A plugin that fails while it is being loaded is **reported and skipped**. A broken extension must not | ||
| + | be able to take the whole installation down, because then it could not be disabled any more. This is | ||
| + | the opposite of the policy for hooks, where a failing callback fails the operation it extends: an | ||
| + | operation | ||
| + | |||
| + | ======= Hooks ======= | ||
| + | |||
| + | Hooks are the general extension mechanism of Admidio and the normal way for a plugin to change what | ||
| + | the core does. Three primitives are offered by ``Admidio\Hooks\Hooks``: | ||
| + | |||
| + | ^ Primitive ^ Purpose ^ Dispatch ^ | ||
| + | | **Action** | Notify that something is happening or has happened. Every callback is called, return values are ignored. | ``Hooks:: | ||
| + | | **Filter** | Transform a value. Every callback receives the result of the previous one. | ``Hooks:: | ||
| + | | **Resolver** | Ask for one answer. Callbacks are asked in priority order until one returns something other than ``null``. | ``Hooks::resolve()`` | | ||
| <code php> | <code php> | ||
| - | [...] | + | // transform a value |
| - | + | Hooks:: | |
| - | | + | |
| - | $cards = array( | + | }); |
| - | array('title' | + | |
| - | array(' | + | // observe an event |
| + | Hooks:: | ||
| + | |||
| + | // ask for one answer | ||
| + | Hooks:: | ||
| + | </ | ||
| + | |||
| + | A lower priority runs earlier, registrations of the same priority run in registration order. A | ||
| + | callback may be registered with an explicit ID — the last argument in the examples on this page is | ||
| + | the plugin ID — which is unique within one hook regardless of the priority, so registering the same | ||
| + | ID again replaces the previous registration. Exceptions of a callback are logged and rethrown: a | ||
| + | failing extension fails the operation it extends instead of leaving it half done. | ||
| + | |||
| + | ======= The overview widget ======= | ||
| + | |||
| + | The overview page used to include one PHP file per plugin through a Smarty function, so a plugin | ||
| + | could only contribute there by being a file at a place the core knew. **A widget is now an ordinary | ||
| + | hook**: the overview asks for the widgets, and every plugin that wants one adds it. | ||
| + | |||
| + | A plugin that follows the Admidio conventions — one widget, an ``_overview_sequence`` preference for | ||
| + | its position and a ``_plugin_enabled`` preference for who may see it — does not write that hook | ||
| + | itself but calls ``PluginWidget:: | ||
| + | |||
| + | <code php> | ||
| + | PluginWidget:: | ||
| + | ' | ||
| + | )); | ||
| + | </code> | ||
| + | |||
| + | ^ Option ^ Default ^ | ||
| + | | ``name`` | The name of the plugin, translated if it is a language string ID | | ||
| + | | ``icon`` | The icon of the plugin | | ||
| + | | ``sequencePreference`` | ``< | ||
| + | | ``enabledPreference`` | ``< | ||
| + | | ``sequence`` | Position of a widget whose preference has no value | | ||
| + | |||
| + | The render callback receives the overview page and the plugin and returns the HTML of the widget, or | ||
| + | an empty string for a widget that has nothing to show. ``PluginWidget:: | ||
| + | of the plugin | ||
| + | |||
| + | The **declaration** is what the overview preferences list, so a widget that is switched off is still | ||
| + | shown there and can be switched on again. | ||
| + | |||
| + | A plugin that needs more control adds the hook itself: | ||
| + | |||
| + | <code php> | ||
| + | Hooks:: | ||
| + | global $gValidLogin, $gL10n; | ||
| + | |||
| + | if (!$gValidLogin) { | ||
| + | | ||
| + | } | ||
| + | |||
| + | $plugin = PluginRegistry:: | ||
| + | $widgets[] = array( | ||
| + | | ||
| + | | ||
| + | | ||
| + | ' | ||
| ); | ); | ||
| - | | + | |
| - | $smarty-> | + | |
| - | | + | }); |
| - | } | + | |
| </ | </ | ||
| - | The corresponding wrapper template is shown below: | + | ======= |
| - | <code smarty> | + | A plugin that brings preferences shows them in the Admidio preferences, |
| - | {foreach $cards as $index => $card} | + | names. It used to get there by being **scanned for**: the core looked through the autoload |
| - | < | + | directories for a file called ``*PreferencesPresenter.php`` and guessed the method to call from its |
| - | < | + | class name. **A panel is now declared**, like a widget, so that a plugin says what it offers instead |
| - | < | + | of the core deducing it from file names. |
| - | {include | + | |
| - | </ | + | For a plugin whose form is generated from the manifest, the manifest is all there is: |
| - | </div> | + | |
| - | {/foreach} | + | <code json> |
| + | " | ||
| </ | </ | ||
| - | This template creates cards for each form element defined in the //$cards// variable and loads the corresponding template file. It is necessary to pass the index of the zero-based $cards array to the corresponding template file to retrieve the correct smarty data that is now also available as a zero-based array for each form variable. | + | A plugin that builds its form itself registers |
| - | These are: | + | |
| - | * // | + | |
| - | * // | + | |
| - | * // | + | |
| - | * // | + | |
| - | * // | + | |
| - | This following example shows a possible implementation of the first form (so with index 0): | + | <code php> |
| + | Hooks:: | ||
| + | PluginPanel:: | ||
| + | static function (array $panels) use ($plugin): array { | ||
| + | global $gL10n; | ||
| - | <code smarty> | + | |
| - | <form {foreach | + | ' |
| - | {$attribute@key}="{$attribute}" | + | ' |
| - | | + | ' |
| + | ' | ||
| + | ' | ||
| + | ); | ||
| - | {include ' | + | return |
| - | | + | }, |
| - | | + | |
| - | | + | |
| - | | + | $plugin->id |
| - | {include 'sys-template-parts/ | + | ); |
| - | | + | </code> |
| - | | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | {include ' | + | |
| - | <div class=" | + | The panel takes its section and its sequence from the manifest of the plugin it is named after, so |
| - | </form> | + | only a plugin that declares **several** panels has to name a ``section`` here as well. |
| - | {$javascript[$dataIndex]} | + | |
| + | The ``create`` callback receives the ``PreferencesPresenter`` and returns the HTML of the panel. It | ||
| + | is only called for the panel the administrator actually opens, because the preferences page fetches | ||
| + | one panel per request. | ||
| + | |||
| + | The settings of a plugin are additionally reachable as a dialog from the plugin administration | ||
| + | itself, so a plugin does not have to be found in the preferences tabs to be configured. | ||
| + | |||
| + | ======= Pages ======= | ||
| + | |||
| + | **modules/ | ||
| + | ``admFuncVariableIsValid()``, | ||
| + | ``handleException()``. Nothing about it is plugin-specific. | ||
| + | |||
| + | Only the files **directly below** ``modules/ | ||
| + | recursively, | ||
| + | the entry file, the classes below ``src/`` — is refused if it is requested directly. | ||
| + | |||
| + | A page is always reachable at its real location: | ||
| + | |||
| + | <code text> | ||
| + | plugins/ | ||
| </ | </ | ||
| - | This makes it possible | + | When the administrator switches on the preference **plugin_module_pages**, |
| + | publishes | ||
| + | |||
| + | <code text> | ||
| + | modules/ | ||
| + | </ | ||
| + | |||
| + | Admidio has no front controller and no rewrite rules — every module URL is a real file — so | ||
| + | publishing writes one generated stub per page, which loads the Admidio bootstrap and hands over to | ||
| + | the page inside the plugin. The page itself is unchanged and works through either URL, because | ||
| + | ``__DIR__`` is per file: the relative includes of the page still resolve against its real location. | ||
| + | Because this writes into the Admidio core tree, which a Git-managed or read-only deployment does not | ||
| + | want, it is **off by default**. A generated directory carries a marker file naming the plugin it | ||
| + | belongs | ||
| + | removed by a plugin whatever | ||
| + | |||
| + | <WRAP center round tip 80%> | ||
| + | Never hardcode either form — ask ``$plugin-> | ||
| + | </ | ||
| + | |||
| + | A plugin with a page gets a menu entry below // | ||
| + | pages are not destinations — the calendar answers one month of itself for its own buttons — says | ||
| + | ``" | ||
| + | |||
| + | ======= Languages, templates and assets ======= | ||
| + | |||
| + | * **languages/ | ||
| + | * **templates/** is added to the Smarty template search path. ``$plugin-> | ||
| + | * **assets/** holds css, js and images. ``$plugin-> | ||
| - | ===== Entity, Service and ValueObjects classes | + | ======= Database scripts ======= |
| - | If the plugin has specific //Entity//, // | + | |
| - | ====== | + | **db_scripts/** is optional and holds |
| - | This folder | + | |
| - | In addition to database scripts update scripts (e.g.: // | + | * ``install.sql`` — run when the plugin is installed, |
| + | * ``uninstall.sql`` — run when the plugin | ||
| + | * ``update_x_y.xml`` — the update scripts, structured exactly like Admidio's own. | ||
| - | Similar to Admidio' | + | The update scripts |
| + | steps. | ||
| + | is the first ``autoload`` prefix of the manifest followed by ``Service\``, | ||
| + | ``" | ||
| + | ``AdmidioPlugin\HelloWorld\Service\UpdateStepsCode`` in ``src/Service/UpdateStepsCode.php``: | ||
| - | The class implementation should look like this: | ||
| <code php> | <code php> | ||
| final class UpdateStepsCode | final class UpdateStepsCode | ||
| { | { | ||
| - | /** | ||
| - | * @var Database | ||
| - | */ | ||
| private static Database $db; | private static Database $db; | ||
| - | /** | ||
| - | * Set the database | ||
| - | * @param Database $database The database instance | ||
| - | */ | ||
| public static function setDatabase(Database $database) | public static function setDatabase(Database $database) | ||
| { | { | ||
| Line 272: | Line 461: | ||
| } | } | ||
| - | | + | public static function /*[method name used in update_x_y.xml]*/() |
| - | * @throws Exception | + | |
| - | */ | + | |
| - | | + | |
| { | { | ||
| [...] | [...] | ||
| Line 282: | Line 468: | ||
| </ | </ | ||
| - | ====== | + | ======= Command line tasks ======= |
| - | This folder contains the plugin-specific translation files. | + | |
| - | ====== templates folder ====== | + | A plugin owns the CLI command namespace named after its directory, exactly like a module. Where a |
| - | To move away from the deprecated | + | module registers its commands in ``modules/< |
| + | **plugins/< | ||
| + | so the autoload mappings, settings | ||
| + | metadata and callbacks; the command itself runs later, after the actor and the component have been | ||
| + | authorized. | ||
| - | ====== Root plugin folder ====== | ||
| - | | ||
| - | | ||
| - | ===== Main plugin file ===== | ||
| - | Inside the root plugin folder a main plugin file is required. This file can contain plugin-specific logic (similar to Admidio’s module entry files) or simply initialize the [[en: | ||
| <code php> | <code php> | ||
| - | <?php | + | CliTaskRegistry:: |
| - | use Birthday\classes\Birthday; | + | ' |
| + | ' | ||
| + | array(Greeting:: | ||
| + | 'Print the greeting of the plugin.', | ||
| + | ' | ||
| + | ); | ||
| + | </ | ||
| - | /** | + | A command name must have the form ``extension: |
| - | | + | cannot replace a command that is already registered, and a broken ``cli.php`` is reported without |
| - | * Birthday | + | taking the rest of the command line down. |
| - | * | + | |
| - | * The plugin lists all users who have birthday in a defined timespan. | + | |
| - | * | + | |
| - | * @copyright The Admidio Team | + | |
| - | * @see https://www.admidio.org/ | + | |
| - | * @license https://www.gnu.org/ | + | |
| - | | + | |
| - | */ | + | |
| - | try { | + | ====== The plugin:* commands ====== |
| - | require_once(__DIR__ . '/ | + | |
| - | $pluginBirthday = Birthday:: | + | The plugin manager itself is fully available from the command line: |
| - | $pluginBirthday-> | + | |
| - | } catch (Throwable $e) { | + | ^ Command ^ Purpose ^ |
| - | echo $e-> | + | | ``plugin: |
| - | } | + | | ``plugin: |
| - | </ | + | | ``plugin: |
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| + | | ``plugin: | ||
| - | ===== Plugin configuration file ===== | + | ======= The lifecycle ======= |
| - | Each plugin must provide a JSON configuration file containing basic plugin information and configuration (see: [[en: | + | |
| + | A plugin has **two independent states**: | ||
| - | ======= Configuration file ======= | + | * **installed** is global, because the database schema of a plugin is global. It is a row in the components table with ``com_type |
| - | Each plugin | + | * **enabled** is per organization, |
| - | ^ Key: ^ Mandatory: ^ Description: | + | The plugin |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | | " | + | |
| - | The following example shows the structure based on the " | + | ^ State ^ Meaning ^ |
| - | <code json> | + | | ``available`` | The files are there, but the plugin |
| - | { | + | | ``disabled`` | Installed, but not enabled for the current organization. | |
| - | " | + | | ``enabled`` | Installed and enabled for the current organization. | |
| - | " | + | | ``update`` | Installed, but the files declare a newer version |
| - | "version": "1.0.0", | + | | ``broken`` | The manifest is missing, malformed or the requirements are not met. | |
| - | " | + | | ``orphaned`` | Installed in the database, but the files are gone. | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | ], | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | } | + | |
| - | } ], | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | }, | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | " | + | |
| - | } | + | |
| - | } | + | |
| - | } | + | |
| - | </ | + | |
| - | ======= Plugin class ======= | + | The operations are deliberately separate |
| - | The //Plugin// class extends the basic functionality provided by the abstract class // | + | |
| - | If there is nothing to render | + | ^ Operation ^ Scope ^ Touches data ^ |
| - | <code php> | + | | install | installation | creates |
| - | namespace Birthday\classes; | + | | update | installation | runs ``db_scripts/update_x_y.xml`` up to the new version | |
| + | | enable | organization | nothing, only the preference that loads the plugin | | ||
| + | | disable | organization | nothing | | ||
| + | | remove | installation | removes the preferences, | ||
| - | use Admidio\Infrastructure\Plugins\PluginAbstract; | + | **Disabling is therefore always safe and reversible.** Enabling a plugin that was never installed |
| - | use Admidio\UI\Presenter\PagePresenter; | + | installs it first, so the administrator has one decision to make and not two. Removing is the only |
| + | destructive operation there is: it takes the plugin away from **every** organization at once, runs | ||
| + | ``uninstall.sql`` and deletes the files. A plugin of the Admidio | ||
| + | next core update would put its files back and quietly undo the decision. | ||
| - | class Birthday extends PluginAbstract | + | Reading a plugin never executes plugin code and never throws. An unreadable, malformed or |
| - | { | + | incompatible manifest produces a plugin whose ``error`` describes the problem, so the plugin |
| - | | + | administration can list, disable and remove a broken plugin instead of breaking with it. The reason |
| - | { | + | comes from the manifest reader and from ``checkRequirements()``, which answer in English for a |
| - | return true; | + | developer, so it is shown as the diagnostic it is and not as a translated member-facing message. |
| - | } | + | |
| - | } | + | |
| - | </ | + | |
| - | In its simplest implementation for displaying something the // | + | ======= Distributing a plugin ======= |
| - | <code php> | + | |
| - | namespace Birthday\classes; | + | |
| - | use Admidio\Infrastructure\Plugins\PluginAbstract; | + | ====== The archive ====== |
| - | use Admidio\UI\Presenter\PagePresenter; | + | |
| - | class Birthday extends PluginAbstract | + | ``admidio plugin:archive < |
| - | { | + | holds a single top-level directory named after the plugin ID, and it is checked with the very gate |
| - | public static function doRender(? | + | the installer uses before it is handed back. |
| - | { | + | |
| - | if (!isset($page)) { | + | |
| - | throw new Exception(" | + | |
| - | } | + | |
| - | $page->show(); | + | |
| - | + | ||
| - | return true; | + | |
| - | } | + | |
| - | } | + | |
| - | </ | + | |
| - | All methods provided by // | + | These are left out of the archive: ``.git``, ``.svn``, ``.hg``, ``node_modules``, |
| - | <code php> | + | ``tests``, ``phpunit.xml``, |
| - | public static function getPluginConfig() : array | + | ``Thumbs.db``, |
| - | { | + | the tests of every plugin |
| - | // get the plugin | + | have nothing to do with developing the plugin. |
| - | | + | |
| - | // if the key equals ' | + | Everything an archive contains |
| - | if (array_key_exists(' | + | checked before a single byte of it reaches |
| - | && $config[' | + | entries, 32 MB compressed and 128 MB extracted, and a failure at any point removes the temporary |
| - | $config[' | + | directory rather than leaving a partial plugin behind. |
| - | } | + | |
| - | if (array_key_exists(' | + | ====== The plugin store ====== |
| - | && $config[' | + | |
| - | $config[' | + | |
| - | } | + | |
| - | return $config; | + | The store is one JSON file on a web server — by default ``plugins.json`` next to the ``update.txt`` |
| - | } | + | that the version check already reads. Admidio fetches it, caches it in ``adm_my_files`` and refreshes |
| - | </ | + | it at most once a day or when an administrator asks. The remote call never happens while a page is |
| + | being built, and a catalogue that cannot be reached or cannot be understood makes the store | ||
| + | unavailable rather than breaking the page. | ||
| - | ======== PluginAbstract class ======== | + | ``plugin: |
| - | <WRAP center round todo 60%> | + | then run the update scripts — which is all that is needed for a plugin whose files somebody replaced |
| - | methods documentation | + | by hand. Updating is one action whatever |
| - | </WRAP> | + | |
| - | The //PluginAbstract// class is the base class for all plugins usable with the Plugin Manager. | + | |
| - | Provides: | + | ====== Tests ====== |
| - | * installation / uninstallation | + | |
| - | * updates | + | |
| - | * configuration handling | + | |
| - | * dependency checks | + | |
| - | * autoload registration | + | |
| - | * visibility + activation control | + | |
| - | All custom | + | Every test suite of Admidio also reads ``plugins/ |
| + | it lives and with the same command as the core. The example plugin ships | ||
| + | ``plugins/ | ||
| + | |||
| + | ======= API reference ======= | ||
| + | |||
| + | All classes live in the namespace ``Admidio\Infrastructure\Plugins``. | ||
| + | |||
| + | ====== Plugin ====== | ||
| + | |||
| + | One plugin as it exists on disk. Reading it never executes plugin code and never throws. | ||
| + | |||
| + | ^ Property ^ Description ^ | ||
| + | | ``id`` | Plugin ID, which is the directory name | | ||
| + | | ``path`` | Absolute path of the plugin directory | | ||
| + | | ``manifest`` | The decoded manifest, so a plugin may carry metadata the class does not interpret | | ||
| + | | ``name``, ``description``, | ||
| + | | ``autoload`` | PSR-4 mappings, as prefix => absolute directory | | ||
| + | | ``settings`` | The preferences the plugin owns, as name => definition | | ||
| + | | ``requires`` | ``admidio``, | ||
| + | | ``preferences`` | ``section`` and ``sequence`` of the settings panel | | ||
| + | | ``error`` | Why the plugin cannot be used, or ``null`` | | ||
| - | ===== Configuration & Metadata ===== | ||
| ^ Method ^ Description ^ Return ^ | ^ Method ^ Description ^ Return ^ | ||
| - | | getPluginConfig() | Returns merged | + | | ``read(string $directory)`` | Read the plugin |
| - | | getPluginPath() | Absolute | + | | ``isValidId(string $id)`` | Whether a directory name is a usable plugin ID | bool | |
| - | | getComponentId() | Database component ID (components.com_id) | int | | + | | ``isValid()`` | Whether the plugin can be used at all | bool | |
| - | | getComponentName() | Folder name of the plugin | string | | + | | ``getEntryFile()`` |
| - | | getPluginSequence() | Overview sorting sequence from config | + | | ``getDirectory(string $directory)`` | Absolute path of a conventional subdirectory, |
| + | | ``getPages()`` / ``hasPages()`` | ||
| + | | ``wantsMenuEntry()`` | Whether the plugin should get a menu entry | bool | | ||
| + | | ``getUrl(string $page = '' | ||
| + | | ``getSettingValues()`` | Every declared preference with its value, cast to the declared type | array | | ||
| + | | ``renderTemplate(PagePresenter $page, string $template, array $variables)`` | Render a template | ||
| + | | ``getAssetUrl(string $file)`` | URL of a file below ``assets/ | ||
| + | | ``getEnabledSettingName()`` | ``plugin_< | ||
| + | | ``getAccessSettingName()`` | ``< | ||
| + | | ``checkRequirements(array $installedPlugins)`` | One English diagnostic per unsatisfied requirement | array | | ||
| + | | ``getDisabledModules()`` | The Admidio modules the plugin needs that are switched off | array | | ||
| + | | ``versionMatches(string $version, string $constraint)`` | Check a version against a constraint | bool | | ||
| + | |||
| + | ====== PluginRegistry ====== | ||
| + | |||
| + | The only place that answers "which plugins are there" and "may this one run". It never includes | ||
| + | plugin code. Discovery and the installation states are read once per request. | ||
| - | ===== Dependency Handling ===== | ||
| ^ Method ^ Description ^ Return ^ | ^ Method ^ Description ^ Return ^ | ||
| - | | checkDependencies() | Verifies that all declared | + | | ``all()`` | Every plugin |
| + | | ``get(string $id)`` | One plugin, or ``null`` | ?Plugin | | ||
| + | | ``getState(Plugin or string $plugin)`` | One of the six ``STATE_*`` constants | string | | ||
| + | | ``isInstalled(string $id)`` | Installed in this installation | ||
| + | | ``isEnabled(string $id)`` | Enabled for the current organization | bool | | ||
| + | | ``isBuiltIn(string $id)`` | A plugin of the Admidio distribution | bool | | ||
| + | | ``getInstalledVersion(string $id)`` | The version the database records | string | | ||
| + | | ``getComponentId(string $id)`` / ``getComponentUuid(string $id)`` | Component record of the plugin | int / string | | ||
| + | | ``getEnabledOrganizations(string $id)`` | Which organizations have it enabled | array | | ||
| + | | ``getLoadable()`` | The plugins that may be loaded in this request | array | | ||
| + | | ``getEnabledVersions()`` | pluginId => version of every installed and enabled plugin | array | | ||
| + | | ``getOwnerOfSetting(string $name)`` | Which plugin owns a preference | ?Plugin | | ||
| + | | ``requireEnabled(string $id)`` | Refuse the request unless the plugin is enabled | Plugin | | ||
| + | | ``requirePage(string $id, string $page)`` | Guard a plugin page | void | | ||
| + | | ``getScriptPluginId()`` | The plugin the running script belongs to | ? | ||
| - | <code php> | + | ====== PluginLoader ====== |
| - | private findAdmidioClass(string $shortName): | + | |
| - | </ | + | Loads the plugins that are enabled |
| - | Searches | + | |
| - | ===== Installation State ===== | ||
| ^ Method ^ Description ^ Return ^ | ^ Method ^ Description ^ Return ^ | ||
| - | | isInstalled() | Plugin entry exists in database | + | | ``loadEnabled()`` | Load every enabled plugin |
| - | | isActivated() | Installed and active | + | | ``load(Plugin $plugin)`` | Load one plugin |
| - | | isVisible() | Visible | + | | ``getLoaded()`` / ``isLoaded(string $id)`` |
| - | | isOverviewPlugin() | Registered as overview plugin | + | | ``getFailures()`` | pluginId => why it could not be loaded |
| - | | isAdmidioPlugin() | Built-in Admidio overview plugin | + | | ``getTemplateDirectories()`` | The template search path contributed by plugins |
| - | | isUpdateAvailable() | Installed version differs from metadata version | + | | ``registerLanguages(Plugin $plugin)`` | Add the language directory of a plugin |
| + | |||
| + | ====== PluginInstaller ====== | ||
| - | ===== Autoloading ===== | ||
| ^ Method ^ Description ^ Return ^ | ^ Method ^ Description ^ Return ^ | ||
| - | | doClassAutoload(string $class) | Registers PSR-4 autoload mappings from plugin | + | | ``install(Plugin $plugin, bool $addMenuEntry = true)`` | Run ``install.sql``, |
| + | | ``update(Plugin $plugin)`` | Run the update scripts up to the version the files declare | void | | ||
| + | | ``updateWithNewerFiles(Plugin $plugin)`` | Fetch newer files where the store publishes them, then update | Plugin | | ||
| + | | ``remove(Plugin or string $plugin)`` | Remove the plugin | ||
| + | | ``enable(Plugin $plugin)`` / ``disable(Plugin $plugin)`` | The per-organization decision | void | | ||
| + | | ``getPreferenceNames(Plugin $plugin)`` | Every preference the plugin owns | array | | ||
| - | Example metadata: | + | ====== PluginWidget ====== |
| - | <code json> | + | ^ Method ^ Description ^ Return ^ |
| - | " | + | | ``register(Plugin $plugin, callable $render, array $options)`` | Declare the widget and add the hook that renders it | void | |
| - | " | + | | ``render(PagePresenter $page, |
| - | " | + | | ``getDeclarations()`` | Every declared widget, whether visible in this request or not | array | |
| - | } | + | | ``isVisible(string $preference)`` |
| - | } | + | | ``getSequence(string $preference, |
| - | </code> | + | | Constants | ``HOOK``, ``DEFAULT_SEQUENCE``, |
| - | ===== Lifecycle Methods | + | ====== |
| - | ==== doInstall(bool $addMenuEntry = true) ==== | + | |
| - | Installs the plugin. | + | |
| - | Steps: | + | ^ Method ^ Description ^ Return ^ |
| - | * store default config values | + | | ``collect()`` | Every declared preferences panel | array | |
| - | * execute install SQL scripts | + | | ``inSection(string $section)`` | The panels of one preference tab | array | |
| - | * register component | + | | ``get(string $id)`` | One panel declaration | ?array | |
| - | * run update steps | + | | ``create(string $id, PreferencesPresenter $presenter)`` | Build the HTML of one panel | string | |
| - | * set version | + | | ``normalizeId(string $id)`` | The panel ID a plugin ID maps to | string | |
| - | * optionally create menu entry | + | |
| - | Returns: bool | + | ====== PluginPages ====== |
| - | ==== doUninstall(bool $removeMenuEntry = true, array $options = []) ==== | + | ^ Method ^ Description ^ Return ^ |
| - | Removes | + | | ``isAllowed()`` | Whether ``plugin_module_pages`` permits publishing | bool | |
| + | | ``isWritable()`` | Whether the modules directory can be written | bool | | ||
| + | | ``getObstacle(Plugin | ||
| + | | ``isPublished(Plugin or string | ||
| + | | ``getUrl(Plugin $plugin, string $page)`` | The URL that is currently live | string | | ||
| + | | ``publish(Plugin $plugin)`` / ``unpublish(Plugin or string $plugin)`` | Write or remove the stubs | bool / void | | ||
| + | | ``reconcile()`` / ``syncAll()`` | Bring the modules directory in line with the preference | array | | ||
| + | | Constants | ``SETTING`` (``plugin_module_pages``), | ||
| - | Steps: | + | ====== PluginStore ====== |
| - | * execute uninstall SQL scripts | + | |
| - | * delete config values | + | ^ Method ^ Description ^ Return ^ |
| - | * remove menu entries | + | | ``getPlugins()`` | What the catalogue publishes for this Admidio | array | |
| - | * delete component | + | | ``getEntry(string $id)`` | One catalogue entry | ?array | |
| - | * reset version | + | | ``getNewerRelease(string $id)`` | A published release newer than the installed one | ?array | |
| + | | ``install(string $id)`` / ``updateFiles(string $id)`` | Fetch and place the files | string | | ||
| + | | ``refresh()`` | Fetch the catalogue now | array | | ||
| + | | ``getUrl()`` / ``isDeveloperCatalogue()`` / ``getError()`` | Where it comes from and why it failed | string / bool / ?string | | ||
| + | |||
| + | ====== PluginPackage ====== | ||
| + | |||
| + | ^ Method ^ Description ^ Return ^ | ||
| + | | ``install(string $archivePath, | ||
| + | | ``describe(string $archivePath)`` | The plugin an archive holds | Plugin | | ||
| + | | ``create(Plugin $plugin, string $directory)`` | Build the distributable archive | string | | ||
| + | | ``inspect(string $archivePath)`` | The gate: everything it rejects is rejected before a file is written | string | | ||
| + | | Constants | ``MAX_ARCHIVE_BYTES``, | ||
| - | Returns: bool | + | ======= What changed since the first plugin manager ======= |
| - | ==== doUpdate() ==== | + | The plugin manager of Admidio 5.0 was built around a ``PluginAbstract`` base class and a |
| - | Updates an already installed | + | ``plugin.json`` that described it. The runtime was replaced, and a plugin written for it has to be |
| + | converted. The differences that matter to a plugin author: | ||
| - | Steps: | + | ^ Then ^ Now ^ |
| - | * add missing config defaults | + | | A main plugin class extending ``PluginAbstract``, |
| - | * run update | + | | The main file was named by ``mainFile``, |
| - | | + | | ``" |
| + | | ``" | ||
| + | | ``" | ||
| + | | A preferences presenter was found by scanning the autoload directories for ``*PreferencesPresenter.php`` | The panel is declared through the ``PluginPanel`` hook, or generated from the manifest and not written at all | | ||
| + | | ``" | ||
| + | | ``classes/`` with ``Presenter/ | ||
| + | | Install and uninstall were one pair of operations | Four distinct operations: install and update | ||
| + | | ``db-install.sql`` / ``db-uninstall.sql`` | ``install.sql`` / ``uninstall.sql`` in ``db_scripts/ | ||
| + | | Plugins were installed by copying files | Plugin store, ZIP upload and ``admidio plugin:*`` commands | | ||
| - | Returns: bool | + | The generated settings form is the largest simplification: a plugin that only needs ordinary |
| + | preference fields declares them in the manifest and writes no presenter, no template and no form | ||
| + | code at all. | ||
| - | ===== Internal Helpers ===== | ||
| - | ^ Method ^ Description ^ | ||
| - | | toggleForeignKeyChecks(bool $enable) | Enables/ | ||
| - | | initParams(array $params = []) | Optional initialization hook for plugins | ||