Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revision Previous revision
Next revision
Previous revision
en:entwickler:plugin_manager [2026/02/01 16:51] – [Main plugin file] mightymcoderen:entwickler:plugin_manager [2026/10/23 23:22] (current) – kainhofer
Line 1: Line 1:
 ========= Plugin Manager ========= ========= Plugin Manager =========
-{{ :en:entwickler:plugin_manager_overview.png?direct |}} 
-The Plugin Manager module provides a simple user interface and standardized procedures for 
-  * installing, 
-  * uninstalling, 
-  * updating, 
-  * managing and 
-  * integrating 
-plugins in Admidio. 
  
-======== Make your plugin compatible with the Plugin Manager (Admidio 5) ======== +The Plugin Manager is the part of Admidio that knows which plugins exist, in which state they are 
-To make your plugin compatible with the Plugin Manager certain requirements must first be met. These include: +and what has to happen to move them from one state to the next. It provides
-  * [[en:entwickler:plugin_manager#naming_conventions|Naming conventions]] +
-  * A consistent [[en:entwickler:plugin_manager#folder_structure|folder structure]] +
-  * A [[en:entwickler:plugin_manager#plugin_preferences_class|preference presenter class]] with a [[en:entwickler:plugin_manager#single_formpresenter_element|single FormPresenter element]] or with [[en:entwickler:plugin_manager#multiple_formpresenter_elements|multiple FormPresenter elements]] +
-  * A [[en:entwickler:plugin_manager#plugin_class|plugin class]] +
-  * A JSON [[en:entwickler:plugin_manager#configuration_file|configuration file]]+
  
-======= Naming conventions ======= +  * a **plugin administration** page under //Administration → Extensions// for installing, enabling, disabling, updating and removing plugins, 
-To use the new PluginManager some naming conventions must be observed: +  * a **plugin store** so that a plugin can be installed and updated from a published catalogue, 
-  - For better readability file and folder names containing multiple words should follow snake_case (underscore separated) notation. +  * an **upload** for a plugin that arrives as a ZIP archive, 
-  - All folder and file names containing multiple words and are placed inside the //classes// folder should follow UpperCamelCase (PascalCase) notation. +  * a **runtime** that loads the enabled plugins into every request, and 
-  - No plugin file defining a class may contain delimiters such as //"-"// or //"_"// especially if it will be used via a class import. +  * a set of **admidio plugin:*** command line tasks that do the same as the web interface.
-======= Folder structure ======= +
-The basic folder structure of any plugin is shown below using the "Birthday" overview plugin as an example: +
-{{:en:entwickler:folder_structure_birthday.png?direct&400|}}+
  
-====== classes folder ====== +<WRAP center round important 80%> 
-===== Presenter classes ===== +This page describes the plugin system of **Admidio 6.0 and later**. It is a rewrite of the plugin manager 
-==== Plugin-specific classes ==== +that was introduced for Admidio 5.0 and is not compatible with it — see 
-Inside the //Presenter// folder all necessary //PagePresenter// classes of the plugin are placed. These should be similar to the module-specific //PagePresenter// classes.+[[en:entwickler:plugin_manager#what_changed_since_the_first_plugin_manager|What changed]]. 
 +</WRAP>
  
-==== Plugin preferences class ==== +A complete, commented example plugin is part of the Admidio distribution: 
-=== Single FormPresenter element === +**plugins/hello-world/**. It shows every convention in one place, each of them usable on its own, 
-If the plugin provides preferences there should be a class //"[PluginName]**PreferencesPresenter**"//. This class contains a single static preference method following the naming conventions of Admidio's //Preferences// class.+and its own ``README.md`` is the short form of this page. Copy that directory, rename it, and you 
 +have a working plugin.
  
-  +======= What a plugin is =======
  
-Below is an example snippet of the //createBirthdayForm// preference method from the "Birthday" overview plugin: +A plugin is a directory below **plugins/** that contains a manifest **plugin.json** and an entry 
-<code php> +file **plugin.php**. Nothing else is required. A plugin that only registers a hook is exactly those 
-public static function createBirthdayForm(Smarty $smarty): string +two files: no class, no base class, no interface, no settings, no page and no language file.
-{ +
-    global $gL10n, $gCurrentSession;+
  
-    $pluginBirthday = Birthday::getInstance(); +The **directory name is the plugin ID** and the only identity a plugin ever has. It is used in 
-    $formValues = $pluginBirthday::getPluginConfig();+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.
  
-    $formBirthday = new FormPresenter( +====== Folder structure ======
-        'adm_preferences_form_birthday', +
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl', +
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), +
-        null, +
-        array('class' => 'form-preferences') +
-    );+
  
-    $selectBoxEntries = array( +<code text> 
-        '0' => $gL10n->get('SYS_DISABLED'), +plugins/hello-world/ 
-        '1' => $gL10n->get('SYS_ENABLED'), +├── plugin.json                        the manifest — the only required metadata 
-        '2' => $gL10n->get('ORG_ONLY_FOR_REGISTERED_USER') +├── plugin.php                         the entry file — included once when the plugin is loaded 
-    );+├── cli.php                            optional, registers command line tasks of the plugin 
 +├── modules/index.php                  optional pages, ordinary Admidio module files 
 +├── src/Greeting.php                   optional classes, reached through the "autoload" mapping 
 +├── languages/en.xml                   optional language files, added to the language search path 
 +├── templates/plugin.hello-world.tpl   optional Smarty templates, added to the template search path 
 +├── assets/                            optional css/js/images, reachable through getAssetUrl() 
 +├── db_scripts/                        optional install.sql, uninstall.sql and update_x_y.xml 
 +└── tests/                             optional tests, run with the Admidio suite, not distributed 
 +</code>
  
-    $formBirthday->addSelectBox( +None of the subdirectories has to exist and **none of them has to be declared** in the manifest. 
-        'birthday_plugin_enabled', +They are conventions the core looks for.
-        Language::translateIfTranslationStrId($formValues['birthday_plugin_enabled']['name']), +
-        $selectBoxEntries, +
-        array( +
-            'defaultValue' => $formValues['birthday_plugin_enabled']['value'], +
-            'showContextDependentFirstEntry' => false, +
-            'helpTextId' => $formValues['birthday_plugin_enabled']['description'] +
-        ) +
-    );+
  
-    $formBirthday->addCheckbox( +====== Naming conventions ======
-        'birthday_show_names_extern', +
-        Language::translateIfTranslationStrId($formValues['birthday_show_names_extern']['name']), +
-        $formValues['birthday_show_names_extern']['value'], +
-        array('helpTextId' => $formValues['birthday_show_names_extern']['description']) +
-    );+
  
-    [...]+  - Folder and file names containing multiple words use **//kebab-case//** (//dash separated//). 
 +  - Class files below the autoload directory (conventionally //src//) use **//UpperCamelCase//** and must not contain ``-`` or ``_``. 
 +  - The plugin ID — the directory name — is lowercase letters, digits and single hyphens.
  
-    $formBirthday->addSubmitButton( +======= The manifest =======
-        'adm_button_save_birthday', +
-        $gL10n->get('SYS_SAVE'), +
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +
-    );+
  
-    $formBirthday->addToSmarty($smarty); +Each plugin has to provide a JSON manifest **plugin.json**. Only ``name`` and ``version`` really 
-    $gCurrentSession->addFormObject($formBirthday); +matter to the core; everything else is optional. A manifest that cannot be read, is not valid JSON 
-    return $smarty->fetch($pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl');+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. 
 + 
 +^ 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 //Extensions//. A plugin without a page never gets one anyway. | 
 +| ``requires`` | | Version constraints and dependencies, see below. | 
 +| ``autoload`` | | PSR-4 mappings, as namespace prefix => directory inside the plugin. | 
 +| ``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->manifest``. 
 + 
 +====== requires ====== 
 + 
 +<code json> 
 +"requires": { 
 +    "admidio": ">=5.1", 
 +    "php": ">=8.2", 
 +    "extensions": ["curl"], 
 +    "plugins": {"hello-world": ">=1.1"}, 
 +    "modules": ["events"]
 } }
 </code> </code>
  
 +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``, ``php``, ``extensions`` and ``plugins`` are **requirements**: an unmet one makes the 
-With Admidio 5 the preference page has been redesigned. It is now possible to display multiple "subcards" on one preference panel. To use this feature from a plugin the **//hasSubcards//** configuration flag must be set to //true//. +plugin broken, so it cannot be installed.
  
-In your PluginPreferencePresenter class you can now define multiple FormPresenter elements and add different preferences to each form. The first FormPresenter can now be assigned to the smarty variable like the following example shows:+``modules`` is deliberately **not** a requirement. A switched-off Admidio module is neither the 
 +plugin's fault nor permanent — the plugin is fine, it simply has nothing to show until the module is 
 +switched on again. The plugin administration therefore tells the administrator about it instead of 
 +refusing the plugin. A module is named the way Admidio names its component (``documents-files``, 
 +``photos``, ``messages``), not the way its preference is spelled.
  
-<code php> +====== autoload ====== 
-public static function createBirthdayForm(Smarty $smarty): string+ 
 +<code json> 
 +"autoload": { 
 +    "AdmidioPlugin\\HelloWorld\\": "src/" 
 +} 
 +</code> 
 + 
 +The prefix must **not** start with ``Admidio\``, which belongs to the core and could otherwise be 
 +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> 
 +"preferences": { "section": "overview_extensions", "sequence": 20 } 
 +</code> 
 + 
 +``section`` names one of the preference tabs and ``sequence`` places the panel inside it. The tabs 
 +are ``system``, ``login_security``, ``user_management``, ``communication``, ``content_management``, 
 +``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`` (``string``, ``boolean``, ``integer``, ``enum`` or ``array``), a 
 +``default``, and a ``label`` and ``description`` that may be language string IDs. That is enough for 
 +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, and a plugin may keep state of its own that nobody is meant to type into. 
 + 
 +An ``enum`` lists its permitted values, and may instead name them so that the generated form has 
 +something readable to show: 
 + 
 +<code json> 
 +"values": ["ASC", "DESC"] 
 +"values": {"first_name": "PLG_HELLO_WORLD_ADDRESS_FIRST_NAME", "full_name": "…"} 
 +</code> 
 + 
 +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> 
 +"hello_world_repeat": { "type": "integer", "default": 3, "min": 0, "max": 20, "step": 1 } 
 +</code> 
 + 
 +Three preference names are conventions the core understands: 
 + 
 +^ Preference ^ Meaning ^ 
 +| ``plugin_<id>_enabled`` | Registered by the core for every installed plugin. It is what enables the plugin for one organization; a plugin does not declare it. | 
 +| ``<id>_plugin_enabled`` | Who may see the overview widget of the plugin: ``0`` nobody, ``1`` everybody, ``2`` only logged-in users. | 
 +| ``<id>_overview_sequence`` | Position of the overview widget on the overview page. | 
 + 
 +In ``<id>`` the hyphens of the plugin ID are replaced by underscores. 
 + 
 +====== A complete manifest ====== 
 + 
 +<code json>
 { {
-    [...] +    "name": "PLG_HELLO_WORLD_NAME", 
-     +    "description": "PLG_HELLO_WORLD_DESCRIPTION", 
-    $formBirthday = new FormPresenter( +    "version": "1.1.0", 
-        'adm_preferences_form_birthday', +    "author": "The Admidio Team", 
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.tpl', +    "url": "https://www.admidio.org", 
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), +    "icon": "bi-globe-americas", 
-        null, +    "requires": { 
-        array('class' => 'form-preferences') +        "admidio": ">=5.1", 
-    ); +        "php": ">=8.2" 
-    $selectBoxEntries = array( +    }, 
-        '0' => $gL10n->get('SYS_DISABLED'), +    "autoload": { 
-        '1' => $gL10n->get('SYS_ENABLED'), +        "AdmidioPlugin\\HelloWorld\\": "src/" 
-        '2' => $gL10n->get('ORG_ONLY_FOR_REGISTERED_USER') +    }, 
-    ); +    "settings": { 
-    $formBirthday->addSelectBox( +        "hello_world_greeting": { 
-        'birthday_plugin_enabled', +            "type": "string", 
-        Language::translateIfTranslationStrId($formValues['birthday_plugin_enabled']['name']), +            "default": "Hello world", 
-        $selectBoxEntries, +            "label": "PLG_HELLO_WORLD_GREETING", 
-        array('defaultValue' => $formValues['birthday_plugin_enabled']['value'], 'showContextDependentFirstEntry' => false, 'helpTextId' => $formValues['birthday_plugin_enabled']['description']) +            "description": "PLG_HELLO_WORLD_GREETING_DESC" 
-    ); +        }, 
-     +        "hello_world_address": { 
-    [...] +            "type": "enum", 
-     +            "default": "first_name", 
-    $formBirthday->addSubmitButton( +            "values": { 
-        'adm_button_save_birthday', +                "first_name": "PLG_HELLO_WORLD_ADDRESS_FIRST_NAME", 
-        $gL10n->get('SYS_SAVE'), +                "full_name": "PLG_HELLO_WORLD_ADDRESS_FULL_NAME", 
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +                "login_name": "PLG_HELLO_WORLD_ADDRESS_LOGIN_NAME" 
-    ); +            }, 
-     +            "label": "PLG_HELLO_WORLD_ADDRESS", 
-    $formBirthday->addToSmarty($smarty); +            "description": "PLG_HELLO_WORLD_ADDRESS_DESC" 
-    $gCurrentSession->addFormObject($formBirthday); +        }, 
-     +        "hello_world_decorate_headline": { 
-    [...]+            "type": "boolean", 
 +            "default": false, 
 +            "label": "PLG_HELLO_WORLD_DECORATE_HEADLINE", 
 +            "description": "PLG_HELLO_WORLD_DECORATE_HEADLINE_DESC" 
 +        } 
 +    } 
 +}
 </code> </code>
  
-Now you can define a new FormPresenter for the second "card":+======= 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 
-        'adm_preferences_form_birthday_2', +use Admidio\Hooks\Hooks; 
-        $pluginBirthday::getPluginPath() . '/templates/preferences.plugin.birthday.2.tpl', +use AdmidioPlugin\HelloWorld\Greeting; 
-        SecurityUtils::encodeUrl(ADMIDIO_URL . FOLDER_MODULES . '/preferences.php', array('mode' => 'save', 'panel' => 'birthday')), + 
-        null, +/* 
-        array('class' => 'form-preferences') + * This file is included by the plugin loader and is never an entry point of its own. Without the 
-    ); + * Admidio bootstrap it could do nothing anyway; the guard only turns a PHP error into a message. 
-     + */ 
-    [...] +if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { 
-     +    exit('This page may not be called directly!'); 
-    $formBirthday2->addSubmitButton( +} 
-        'adm_button_save_birthday_2', + 
-        $gL10n->get('SYS_SAVE'), +Hooks::addFilter('page_headline', array(Greeting::class, 'decorateHeadline'), 20, 2, 'hello-world');
-        array('icon' => 'bi-check-lg', 'class' => 'offset-sm-3') +
-    ); +
-     +
-    [...]+
 </code> </code>
  
-This form cannot be added to smarty with the //addToSmarty// method because this would override all values set by the previous form element. Therefore a new method //appendToSmarty// was implemented in the FormPresenter class (currently only available in the //plugin-manager// branch on GitHub). Use this method to add the second form to the smarty variable:+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; 
-     + 
-    $formBirthday2->appendToSmarty($smarty); +Birthday::register();
-    $gCurrentSession->addFormObject($formBirthday2); +
-     +
-    [..]+
 </code> </code>
  
-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 "wrapper template" to load the form specific template files on one page. For this wrapper template you can add a smarty variable containing informations you want to show. In the following example multiple cards should be used for displaying the different forms:+====== What loading a plugin means ====== 
 + 
 +Loading one plugin means four things and nothing else: 
 + 
 +  - its ``autoload`` mappings become resolvable classes, 
 +  - its **languages/** directory is added to the language search path, 
 +  - 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 can be aborted, a request that never reaches the plugin administration cannot be repaired. 
 + 
 +======= 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::doAction()`` | 
 +| **Filter** | Transform a value. Every callback receives the result of the previous one. | ``Hooks::applyFilters()`` | 
 +| **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::addFilter('entity_value', function (mixed $value, string $column) { 
-    //assign card titles, icons and corresponding template files +    return ($column === 'usd_value') ? trim($value) : $value; 
-    $cards = array( +}); 
-        array('title' => $birthdayName, 'icon' => $birthdayIcon, 'templateFile' => $birthdayPath . '/templates/preferences.plugin.birthday.tpl'), + 
-        array('title' => $birthdayName2, 'icon' => $birthdayIcon2, 'templateFile' => $birthdayPath2 . '/templates/preferences.plugin.birthday.2.tpl')+// observe an event 
 +Hooks::addAction('user_created', array($myPlugin, 'onUserCreated'), 20); 
 + 
 +// ask for one answer 
 +Hooks::addResolver('translation_missing', 'myTranslationProvider'); 
 +</code> 
 + 
 +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::register()``: 
 + 
 +<code php> 
 +PluginWidget::register($plugin, array(self::class, 'renderWidget'), array( 
 +    'sequence' => self::DEFAULT_SEQUENCE 
 +)); 
 +</code> 
 + 
 +^ Option ^ Default ^ 
 +| ``name`` | The name of the plugin, translated if it is a language string ID | 
 +| ``icon`` | The icon of the plugin | 
 +| ``sequencePreference`` | ``<plugin id>_overview_sequence`` | 
 +| ``enabledPreference`` | ``<plugin id>_plugin_enabled`` | 
 +| ``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::render()`` renders a template 
 +of the plugin for it. 
 + 
 +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::addFilter(PluginWidget::HOOK, function (array $widgets, PagePresenter $page): array { 
 +    global $gValidLogin, $gL10n; 
 + 
 +    if (!$gValidLogin) { 
 +        return $widgets;   // a widget that decides not to appear simply does not add itself 
 +    } 
 + 
 +    $plugin = PluginRegistry::get('birthday'); 
 +    $widgets[] = array( 
 +        'id' => $plugin->id, 
 +        'name' => $gL10n->get($plugin->name), 
 +        'sequence' => 20, 
 +        'html' => PluginWidget::render($page, $plugin, 'plugin.birthday.tpl', array('birthdays' => $rows))
     );     );
-     + 
-    $smarty->assign('cards', $cards); +    return $widgets; 
-    return $smarty->fetch($birthdayPath . '/templates/preferences.birthday.cards.tpl'); +});
-}+
 </code> </code>
  
-The corresponding wrapper template is shown below:+======= The preferences panel =======
  
-<code smarty> +A plugin that brings preferences shows them in the Admidio preferences, in the tab its manifest 
-{foreach $cards as $index => $card} +names. It used to get there by being **scanned for**: the core looked through the autoload 
-    <div class="card admidio-tabbed-field-group"> +directories for a file called ``*PreferencesPresenter.php`` and guessed the method to call from its 
-        <div class="card-header"><i class="bi {$card.icon} me-1"></i>{$card.title}</div> +class name. **A panel is now declared**, like a widget, so that a plugin says what it offers instead 
-        <div class="card-body"> +of the core deducing it from file names. 
-            {include file=$card.templateFile dataIndex=$index} + 
-        </div> +For a plugin whose form is generated from the manifest, the manifest is all there is: 
-    </div> + 
-{/foreach}+<code json> 
 +"preferences": { "section": "overview_extensions", "sequence": 20 }
 </code> </code>
  
-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 the callback that builds it: 
-These are: + 
-  * //formType//, +<code php> 
-  * //attributes//, +Hooks::addFilter( 
-  * //elements//, +    PluginPanel::HOOK, 
-  * //javascript// and  +    static function (array $panels) use ($plugin): array { 
-  * //hasRequiredFields//.+        global $gL10n; 
 + 
 +        $panels[] = array( 
 +            'id' => PluginPanel::normalizeId($plugin->id), 
 +            'title' => $gL10n->get($plugin->name), 
 +            'icon' => $plugin->icon, 
 +            'sequence' => self::DEFAULT_SEQUENCE, 
 +            'create' => array(BirthdayPreferencesPresenter::class, 'createForm') 
 +        ); 
 + 
 +        return $panels; 
 +    }, 
 +    PluginPanel::DEFAULT_SEQUENCE, 
 +    1, 
 +    $plugin->id 
 +); 
 +</code> 
 + 
 +The panel takes its section and its sequence from the manifest of the plugin it is named after, so 
 +only a plugin that declares **several** panels has to name a ``section`` here as well. 
 + 
 +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 =======
  
-This following example shows a possible implementation of the first form (so with index 0):+**modules/index.php** is a normal Admidio module file: ``require_once`` of ``system/common.php``, 
 +``admFuncVariableIsValid()``, ``PagePresenter``, ``FormPresenter``, services, ``Entity``, 
 +``handleException()``. Nothing about it is plugin-specific.
  
-<code smarty> +Only the files **directly below** ``modules/`` are entry points; the directory is not searched 
-<form {foreach $attributes[$dataIndex] as $attribute} +recursively, so what a page includes stays private to the plugin. Everything else in the plugin — 
-        {$attribute@key}="{$attribute}" +the entry file, the classes below ``src/`` — is refused if it is requested directly.
-    {/foreach}>+
  
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['adm_csrf_token']} +A page is always reachable at its real location:
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_plugin_enabled']} +
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_names_extern']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_show_names']} +
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_age']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_age_salutation']} +
-    {include 'sys-template-parts/form.checkbox.tpl' data=$elements[$dataIndex]['birthday_show_notice_none']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_past']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_future']} +
-    {include 'sys-template-parts/form.input.tpl' data=$elements[$dataIndex]['birthday_show_display_limit']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_show_email_extern']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_roles_view_plugin']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_roles_sql']} +
-    {include 'sys-template-parts/form.select.tpl' data=$elements[$dataIndex]['birthday_sort_sql']} +
-    {include 'sys-template-parts/form.button.tpl' data=$elements[$dataIndex]['adm_button_save_birthday']}+
  
-    <div class="form-alert" style="display: none;">&nbsp;</div> +<code text> 
-</form> +plugins/hello-world/modules/index.php
-{$javascript[$dataIndex]}+
 </code> </code>
  
-This makes it possible to show seperate forms on the same page. There are no limits to the display options whether as cards (as in the example), tabs, or accordions. With this approach each available Bootstrap/HTML display is possible through the smarty templates.+When the administrator switches on the preference **plugin_module_pages**, Admidio additionally 
 +publishes it under the ordinary module URL:
  
-===== Entity, Service and ValueObjects classes ===== +<code text> 
-If the plugin has specific //Entity//, //Service//, or //ValueObjects// classes these should be placed in the corresponding subfolders as is done with the Admidio modules.+modules/hello-world/index.php 
 +</code> 
 + 
 +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 to, and nothing without that marker is ever deleted, so a core module directory cannot be 
 +removed by a plugin whatever the plugin is called. 
 + 
 +<WRAP center round tip 80%> 
 +Never hardcode either form — ask ``$plugin->getUrl('index.php')`` which one is live. 
 +</WRAP> 
 + 
 +A plugin with a page gets a menu entry below //Extensions// when it is installed. A plugin whose 
 +pages are not destinations — the calendar answers one month of itself for its own buttons — says 
 +``"menu": false`` in its manifest and is left out of the menu. 
 + 
 +======= Languages, templates and assets ======= 
 + 
 +  * **languages/** is added to the language search path when the plugin is loaded, so ``$gL10n->get('PLG_HELLO_WORLD_NAME')`` works and the manifest may use language string IDs. The files have the same format as the Admidio language files (``en.xml``, ``de.xml``, …). 
 +  * **templates/** is added to the Smarty template search path. ``$plugin->renderTemplate($page, 'plugin.hello-world.tpl', $variables)`` renders one. The deprecated ``HtmlPage`` and ``HtmlTable`` classes should not be used any more. 
 +  * **assets/** holds css, js and images. ``$plugin->getAssetUrl('style.css')`` answers the URL of one.
  
-===== Main plugin class ===== +======= Database scripts =======
-The main [[en:entwickler:plugin_manager#plugin_class|plugin class]] (e.g.: //Birthday//) must be placed directly in the //classes// folder.+
  
-====== db_scripts folder ====== +**db_scripts/** is optional and holds
-This folder is optional and is used to place plugin-specific database scripts used when installing (//db-install.sql//) or uninstalling (//db-uninstall.sql//) the plugin.+
  
-In addition to database scripts update scripts (e.g.: //update_1_0.xml//) for the plugin can be placed in this folder. These should be structured like the update scripts used by Admidio to update to a new version.+  * ``install.sql`` — run when the plugin is installed, 
 +  * ``uninstall.sql`` — run when the plugin is removed, 
 +  * ``update_x_y.xml`` — the update scripts, structured exactly like Admidio's own.
  
-Similar to Admidio's update scripts it is possible to call plugin-specific update steps. To implement these steps a final class named //UpdateStepsCode// must be present in the folder //"[PluginName]\classes\Service\"//.+The update scripts are resolved through the plugin ID, and they may call plugin-specific update 
 +steps. Those live in a class ``UpdateStepsCode`` in the **Service** namespace of the plugin — that 
 +is the first ``autoload`` prefix of the manifest followed by ``Service\``, so for the mapping 
 +``"AdmidioPlugin\\HelloWorld\\": "src/"`` the class is 
 +``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 269: Line 461:
     }     }
  
-    /** +    public static function /*[method name used in update_x_y.xml]*/()
-     * @throws Exception +
-     */ +
-    public static function /*[method name used in update_x_x.xml]*/()+
     {     {
         [...]         [...]
Line 279: Line 468:
 </code> </code>
  
-====== languages folder ====== +======= 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 //HtmlPage// and //HtmlTable// classes plugins should exclusively use the Smarty template engine with files stored in this folder.+module registers its commands in ``modules/<module>/cli.php``, a plugin registers them in 
 +**plugins/<plugin>/cli.php**. The file is loaded for every enabled plugin after the plugin runtime, 
 +so the autoload mappings, settings and languages of the plugin are available. It only registers 
 +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:entwickler:plugin_manager#plugin_class|plugin class]] as in the following example: 
 <code php> <code php>
-<?php +CliTaskRegistry::register( 
-use Birthday\classes\Birthday;+    'hello-world:greet', 
 +    'PLUGINS', 
 +    array(Greeting::class, 'cliGreet'), 
 +    'Print the greeting of the plugin.', 
 +    'hello-world:greet [--name=NAME]' 
 +); 
 +</code>
  
-/** +A command name must have the form ``extension:task`` and must name an Admidio component; a plugin 
- *********************************************************************************************** +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/licenses/gpl-2.0.html GNU General Public License v2.0 only +
- *********************************************************************************************** +
- */+
  
-try { +====== The plugin:* commands ======
-    require_once(__DIR__ . '/../../system/common.php');+
  
-    $pluginBirthday = Birthday::getInstance(); +The plugin manager itself is fully available from the command line:
-    $pluginBirthday->doRender(isset($page) ? $page : null);+
  
-} catch (Throwable $e) { +^ Command ^ Purpose ^ 
-    echo $e->getMessage(); +| ``plugin:list [--state=STATE] [--installed] [--updates]`` | List the plugins and their state. | 
-} +| ``plugin:show PLUGIN`` | Show the manifest and the state of a plugin. | 
-</code>+| ``plugin:install SOURCE [--replace]`` | Install a plugin from the store or from an archive file. | 
 +| ``plugin:inspect FILE`` | Show what a plugin archive holds, without installing it. | 
 +| ``plugin:enable PLUGIN`` | Enable a plugin for the current organization, preparing it if that has not happened yet. | 
 +| ``plugin:disable PLUGIN`` | Disable a plugin for the current organization, keeping its data. | 
 +| ``plugin:update PLUGIN`` | Update a plugin, fetching newer files where the store publishes them. | 
 +| ``plugin:remove PLUGIN [--yes]`` | Remove a plugin from every organization, with its data and its files. | 
 +| ``plugin:archive PLUGIN [--output=FILE] [--overwrite]`` | Build the distributable ZIP archive of a plugin. | 
 +| ``plugin:store-list [--available] [--updates]`` | List the plugins the store publishes for this Admidio. | 
 +| ``plugin:store-show PLUGIN`` | Show what the store publishes for one plugin. | 
 +| ``plugin:store-refresh`` | Fetch the plugin catalogue now instead of waiting for the cache to expire. |
  
-===== Plugin configuration file ===== +======= The lifecycle =======
-Each plugin must provide a JSON configuration file containing basic plugin information and configuration (see: [[en:entwickler:plugin_manager#configuration_file|Configuration file]]).+
  
 +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 = PLUGIN`` and ``com_name_intern = <plugin ID>``. 
-Each plugin has to provide a JSON configuration file. The following table gives an overview of all keys currently available:+  * **enabled** is per organization, because an Admidio installation may serve several organizations that do not want the same extensions. It is the preference ``plugin_<id>_enabled``.
  
-^ Key: ^ Mandatory: ^ Description: ^ +The plugin administration groups every plugin it knows by six states:
-| "name" |  X  | Defines the plugin name (can be a translation ID or plain text). | +
-| "description" | | A short description of the plugin shown on the Plugin Manager overview (can be a translation ID or plain text). | +
-| "version" |  X  | The current version of the plugin. | +
-| "author" | | The author of the plugin. | +
-| "url" | | A URL to a wiki page or a project page. | +
-| "icon" | | The icon displayed in the Admidio sidebar and on the Plugin Manager overview. | +
-| "mainFile" | | The filename of the main plugin entry point. If not set "index.php" or "PluginName.php" is assumed. | +
-| "overviewPlugin" | | This optional flag is used to determinate whether the plugin should be installed as an overview plugin. | +
-| "autoload" |  (X)  | An object containing another object "psr-4" which contains the psr4 autoload mapping(s) of the plugin class(es). | +
-| "preferencesFile" | | The filename of the preferences entry point for the plugin settings. If this key is set there is no integration of the plugin settings into the Admidio Preferences Page. | +
-| "hasSubcards" | | This optional flag is used to determinate whether a plugins preference page contains multiple "cards" to display (for CSS styling purposes only). | +
-| "dependencies" |  (X)  | An array of all dependencies the plugin needs to work properly. If this is not set the Plugin Manager cannot detect whether the plugin can be installed properly. | +
-| "defaultConfig" |  (X)  | An object containing all configuration parameters used by the plugin. Every parameter should contain a "name", "description", "type" and "value" key. |+
  
-The following example shows the structure based on the "Birthday" overview plugin: +^ State ^ Meaning ^ 
-<code json> +| ``available`` | The files are there, but the plugin was never installed. | 
-{ +| ``disabled`` | Installed, but not enabled for the current organization. | 
-    "name": "PLG_BIRTHDAY_PLUGIN_NAME", +| ``enabled`` | Installed and enabled for the current organization. | 
-    "description": "PLG_BIRTHDAY_PLUGIN_DESCRIPTION", +| ``update`` | Installed, but the files declare a newer version than the database. | 
-    "version": "1.0.0", +| ``broken`` | The manifest is missing, malformed or the requirements are not met. | 
-    "author": "Admidio Team", +| ``orphaned`` | Installed in the database, but the files are gone. |
-    "url": "https://www.admidio.org", +
-    "icon": "bi-cake2", +
-    "mainFile": "index.php", +
-    "overviewPlugin": true, +
-    "autoload": { +
-        "psr-4": { +
-            "Birthday\\classes\\": "classes/" +
-        } +
-    }, +
-    "dependencies": [ +
-        "Admidio\\Infrastructure\\Plugins\\Overview", +
-        "Admidio\\Infrastructure\\Plugins\\PluginAbstract", +
-        "Admidio\\Infrastructure\\Utils\\SecurityUtils", +
-        "Admidio\\Roles\\Service\\RolesService" +
-    ], +
-    "defaultConfig": { +
-        "birthday_plugin_enabled": { +
-            "name": "ORG_ACCESS_TO_PLUGIN", +
-            "description": "ORG_ACCESS_TO_PLUGIN_DESC", +
-            "type": "integer", +
-            "value" : 1 +
-        }, +
-        "birthday_overview_sequence": { +
-            "type": "integer", +
-            "value" : 2 +
-        }, +
-        "birthday_show_names_extern": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN_DESC", +
-            "type": "boolean", +
-            "value" : false +
-        }, +
-        "birthday_show_names": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_DESC", +
-            "type": "integer", +
-            "value" : 0 +
-        }, +
-        "birthday_show_age": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_DESC", +
-            "type": "boolean", +
-            "value" : false +
-        }, +
-        "birthday_show_age_salutation": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION_DESC", +
-            "type": "integer", +
-            "value" : 18 +
-        }, +
-        "birthday_show_notice_none": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE_DESC", +
-            "type": "boolean", +
-            "value" : true +
-        }, +
-        "birthday_show_past": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST_DESC", +
-            "type": "integer", +
-            "value" : 1 +
-        }, +
-        "birthday_show_future": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE_DESC", +
-            "type": "integer", +
-            "value" : 2 +
-        }, +
-        "birthday_show_display_limit": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT_DESC", +
-            "type": "integer", +
-            "value" : 200 +
-        }, +
-        "birthday_show_email_extern": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN_DESC", +
-            "type": "integer", +
-            "value" : 0 +
-        }, +
-        "birthday_roles_view_plugin": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN_DESC", +
-            "type": "array", +
-            "value" : ["All"] +
-        }, +
-        "birthday_roles_sql": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL_DESC", +
-            "type": "array", +
-            "value" : ["All"] +
-        }, +
-        "birthday_sort_sql": { +
-            "name": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL", +
-            "description": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL_DESC", +
-            "type": "string", +
-            "value" : "DESC" +
-        } +
-    } +
-} ], +
-  "defaultConfig": { +
-    "birthday_plugin_enabled": { +
-      "name": "ORG_ACCESS_TO_PLUGIN", +
-      "description": "ORG_ACCESS_TO_PLUGIN_DESC", +
-      "type": "integer", +
-      "value": 1 +
-    }, +
-    "birthday_show_names_extern": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_EXTERN_DESC", +
-      "type": "boolean", +
-      "value": false +
-    }, +
-    "birthday_show_names": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NAMES_DESC", +
-      "type": "integer", +
-      "value": 0 +
-    }, +
-    "birthday_show_age": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_DESC", +
-      "type": "boolean", +
-      "value": false +
-    }, +
-    "birthday_show_age_salutation": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_AGE_SALUTATION_DESC", +
-      "type": "integer", +
-      "value": 18 +
-    }, +
-    "birthday_show_notice_none": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_NOTICE_NONE_DESC", +
-      "type": "boolean", +
-      "value": true +
-    }, +
-    "birthday_show_past": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_PAST_DESC", +
-      "type": "integer", +
-      "value": 1 +
-    }, +
-    "birthday_show_future": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_FUTURE_DESC", +
-      "type": "integer", +
-      "value": 2 +
-    }, +
-    "birthday_show_display_limit": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_DISPLAY_LIMIT_DESC", +
-      "type": "integer", +
-      "value": 200 +
-    }, +
-    "birthday_show_email_extern": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SHOW_EMAIL_EXTERN_DESC", +
-      "type": "integer", +
-      "value": 0 +
-    }, +
-    "birthday_roles_view_plugin": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_VIEW_PLUGIN_DESC", +
-      "type": "array", +
-      "value": ["All"] +
-    }, +
-    "birthday_roles_sql": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_ROLES_SQL_DESC", +
-      "type": "array", +
-      "value": ["All"] +
-    }, +
-    "birthday_sort_sql": { +
-      "name": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL", +
-      "description": "PLG_BIRTHDAY_PREFERENCES_SORT_SQL_DESC", +
-      "type": "string", +
-      "value": "DESC" +
-    } +
-  } +
-} +
-</code>+
  
-======= Plugin class ======= +The operations are deliberately separate and mean different things:
-The //Plugin// class extends the basic functionality provided by the abstract class //PluginAbstract// and implements the //doRender// method.+
  
-In its simplest implementation the //doRender// method can just call the //PagePresenter// //show// method: +^ Operation ^ Scope ^ Touches data ^ 
-<code php> +| install | installation | creates the schema and the preferences of the plugin | 
-public static function doRender(?PagePresenter $page = null) : bool +| update | installation | runs ``db_scripts/update_x_y.xml`` up to the new version | 
-{ +| enable | organization | nothing, only the preference that loads the plugin | 
-    if (!isset($page)) { +| disable | organization | nothing | 
-        throw new Exception("The page parameter must be set."); +| remove | installation | removes the preferences, the data, the component record and the files |
-    } +
-    $page->show();+
  
-    return true; +**Disabling is therefore always safe and reversible.** Enabling a plugin that was never installed 
-} +installs it first, so the administrator has one decision to make and not two. Removing is the only 
-</code>+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 distribution is refused, because the 
 +next core update would put its files back and quietly undo the decision.
  
-All methods provided by //PluginAbstract// can be overridden in this plugin class and adapted to the plugin's needs. The following code shows an example override of the //getPluginConfig()// method in the "Birthday" overview plugin implementation: +Reading a plugin never executes plugin code and never throws. An unreadable, malformed or 
-<code php> +incompatible manifest produces a plugin whose ``error`` describes the problem, so the plugin 
-public static function getPluginConfig() : array +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 
-    // get the plugin config from the parent class +developer, so it is shown as the diagnostic it is and not as a translated member-facing message.
-    $config = parent::getPluginConfig();+
  
-    // if the key equals 'birthday_roles_view_plugin' and the value is still the default value, retrieve the categories from the database +======= Distributing a plugin =======
-    if (array_key_exists('birthday_roles_view_plugin', $config) +
-        && $config['birthday_roles_view_plugin']['value'] === self::$defaultConfig['birthday_roles_view_plugin']['value']) { +
-        $config['birthday_roles_view_plugin']['value'] = self::getAvailableRoles(1, true); +
-    }+
  
-    if (array_key_exists('birthday_roles_sql', $config) +====== The archive ======
-        && $config['birthday_roles_sql']['value'] === self::$defaultConfig['birthday_roles_sql']['value']) { +
-        $config['birthday_roles_sql']['value'] = self::getAvailableRoles(1, true); +
-    }+
  
-    return $config; +``admidio plugin:archive <plugin>`` builds the ZIP archive that distributes a plugin. The archive 
-} +holds a single top-level directory named after the plugin ID, and it is checked with the very gate 
-</code>+the installer uses before it is handed back. 
 + 
 +These are left out of the archive: ``.git``, ``.svn``, ``.hg``, ``node_modules``, ``vendor``, 
 +``tests``, ``phpunit.xml``, ``phpunit.xml.dist``, ``.phpunit.result.cache``, ``.DS_Store``, 
 +``Thumbs.db``, ``.gitignore`` and ``.gitattributes``. The tests are excluded because Admidio runs 
 +the tests of every plugin that is present, and a distributed one would run in installations that 
 +have nothing to do with developing the plugin. 
 + 
 +Everything an archive contains is written by somebody the installation does not know, so it is 
 +checked before a single byte of it reaches the plugins directory. An archive may hold at most 5000 
 +entries, 32 MB compressed and 128 MB extracted, and a failure at any point removes the temporary 
 +directory rather than leaving a partial plugin behind. 
 + 
 +====== The plugin store ====== 
 + 
 +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. 
 + 
 +``plugin:update`` and the //Update// action fetch newer files where the store publishes them and 
 +then run the update scripts — which is all that is needed for a plugin whose files somebody replaced 
 +by hand. Updating is one action whatever the plugin needs. 
 + 
 +====== Tests ====== 
 + 
 +Every test suite of Admidio also reads ``plugins/*/tests/<suite>``, so a plugin is tested from where 
 +it lives and with the same command as the core. The example plugin ships 
 +``plugins/hello-world/tests/Unit/ManifestTest.php`` as a starting point. 
 + 
 +======= 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``, ``version``, ``icon``, ``author``, ``homepage`` | Metadata | 
 +| ``autoload`` | PSR-4 mappings, as prefix => absolute directory | 
 +| ``settings`` | The preferences the plugin owns, as name => definition | 
 +| ``requires`` | ``admidio``, ``php``, ``extensions``, ``plugins``, ``modules`` | 
 +| ``preferences`` | ``section`` and ``sequence`` of the settings panel | 
 +| ``error`` | Why the plugin cannot be used, or ``null`` | 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``read(string $directory)`` | Read the plugin in a directory | Plugin | 
 +| ``isValidId(string $id)`` | Whether a directory name is a usable plugin ID | bool | 
 +| ``isValid()`` | Whether the plugin can be used at all | bool | 
 +| ``getEntryFile()`` | Absolute path of ``plugin.php`` | string | 
 +| ``getDirectory(string $directory)`` | Absolute path of a conventional subdirectory, or ``null`` | ?string | 
 +| ``getPages()`` / ``hasPages()`` | The PHP files directly below ``modules/`` | array / bool | 
 +| ``wantsMenuEntry()`` | Whether the plugin should get a menu entry | bool | 
 +| ``getUrl(string $page = '')`` | URL of a page, in whichever form is currently live | string | 
 +| ``getSettingValues()`` | Every declared preference with its value, cast to the declared type | array | 
 +| ``renderTemplate(PagePresenter $page, string $template, array $variables)`` | Render a template of the plugin | string | 
 +| ``getAssetUrl(string $file)`` | URL of a file below ``assets/`` | string | 
 +| ``getEnabledSettingName()`` | ``plugin_<id>_enabled`` | string | 
 +| ``getAccessSettingName()`` | ``<id>_plugin_enabled`` | string | 
 +| ``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. 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``all()`` | Every plugin below ``plugins/`` | array | 
 +| ``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 | bool | 
 +| ``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 | ?string | 
 + 
 +====== PluginLoader ====== 
 + 
 +Loads the plugins that are enabled for the current request. 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``loadEnabled()`` | Load every enabled plugin | void | 
 +| ``load(Plugin $plugin)`` | Load one plugin | bool | 
 +| ``getLoaded()`` / ``isLoaded(string $id)`` | What was loaded in this request | array / bool | 
 +| ``getFailures()`` | pluginId => why it could not be loaded | array | 
 +| ``getTemplateDirectories()`` | The template search path contributed by plugins | array | 
 +| ``registerLanguages(Plugin $plugin)`` | Add the language directory of a plugin | void | 
 + 
 +====== PluginInstaller ====== 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``install(Plugin $plugin, bool $addMenuEntry = true)`` | Run ``install.sql``, create the component record, seed the preferences, publish the pages, add the menu entry | void | 
 +| ``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 from every organization, with its data and its files | bool | 
 +| ``enable(Plugin $plugin)`` / ``disable(Plugin $plugin)`` | The per-organization decision | void | 
 +| ``getPreferenceNames(Plugin $plugin)`` | Every preference the plugin owns | array | 
 + 
 +====== PluginWidget ====== 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``register(Plugin $plugin, callable $render, array $options)`` | Declare the widget and add the hook that renders it | void | 
 +| ``render(PagePresenter $page, Plugin $plugin, string $template, array $variables)`` | Render a template as the widget body | string | 
 +| ``getDeclarations()`` | Every declared widget, whether visible in this request or not | array | 
 +| ``isVisible(string $preference)`` / ``getAccess(string $preference)`` | Who may see the widget | bool / int | 
 +| ``getSequence(string $preference, int $default)`` | Position of the widget | int | 
 +| Constants | ``HOOK``, ``DEFAULT_SEQUENCE``, ``ACCESS_NOBODY``, ``ACCESS_EVERYBODY``, ``ACCESS_REGISTERED_USERS`` | | 
 + 
 +====== PluginPanel ====== 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``collect()`` | Every declared preferences panel | array | 
 +| ``inSection(string $section)`` | The panels of one preference tab | array | 
 +| ``get(string $id)`` | One panel declaration | ?array | 
 +| ``create(string $id, PreferencesPresenter $presenter)`` | Build the HTML of one panel | string | 
 +| ``normalizeId(string $id)`` | The panel ID a plugin ID maps to | string | 
 + 
 +====== PluginPages ====== 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``isAllowed()`` | Whether ``plugin_module_pages`` permits publishing | bool | 
 +| ``isWritable()`` | Whether the modules directory can be written | bool | 
 +| ``getObstacle(Plugin $plugin)`` | Why a plugin cannot be published, or ``null`` | ?string | 
 +| ``isPublished(Plugin or string $plugin)`` | Whether the stubs exist | bool | 
 +| ``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``), ``SETTING_APPLIED`` | | 
 + 
 +====== PluginStore ====== 
 + 
 +^ Method ^ Description ^ Return ^ 
 +| ``getPlugins()`` | What the catalogue publishes for this Admidio | array | 
 +| ``getEntry(string $id)`` | One catalogue entry | ?array | 
 +| ``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, bool $replace = false)`` | Put the plugin of an archive on disk. Nothing is written to the database and the plugin is not enabled | string | 
 +| ``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``, ``MAX_EXTRACTED_BYTES``, ``MAX_ENTRIES``, ``EXCLUDED`` | | 
 + 
 +======= What changed since the first plugin manager ======= 
 + 
 +The plugin manager of Admidio 5.0 was built around a ``PluginAbstract`` base class and a 
 +``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: 
 + 
 +^ Then ^ Now ^ 
 +| A main plugin class extending ``PluginAbstract``, with ``doRender()``, ``getInstance()`` and overridable lifecycle methods | **No plugin class at all.** ``plugin.php`` registers what the plugin contributes and returns nothing | 
 +| The main file was named by ``mainFile``, or guessed as ``index.php`` / ``PluginName.php`` | The entry file is always ``plugin.php``; pages are ordinary module files below ``modules/`` | 
 +| ``"dependencies"``: a list of Admidio class names that had to exist | ``"requires"``: version constraints for ``admidio`` and ``php``, plus ``extensions``, ``plugins`` and ``modules`` | 
 +| ``"defaultConfig"``, each entry with ``name``, ``description``, ``type``, ``value`` | ``"settings"``, each entry with ``type``, ``default``, ``label``, ``description``, plus ``values`` for an enum and ``min``/``max``/``step`` for an integer | 
 +| ``"overviewPlugin": true`` made a plugin an overview plugin | The plugin registers an overview widget with ``PluginWidget::register()`` | 
 +| 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 | 
 +| ``"preferencesFile"`` and ``"hasSubcards"`` | Replaced by the declared panel; a plugin that wants several cards declares several panels | 
 +| ``classes/`` with ``Presenter/``, ``Entity/``, ``Service/`` | Any layout the ``autoload`` mapping names; ``src/`` by convention | 
 +| Install and uninstall were one pair of operations | Four distinct operations: install and update are installation-wide, enable and disable are per organization, remove is the only destructive one | 
 +| ``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 | 
 + 
 +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.
  
-======== PluginAbstract class ======== 
-<WRAP center round todo 60%> 
-methods documentation 
-</WRAP> 
  • en/entwickler/plugin_manager.1769961099.txt.gz
  • Last modified: 2026/02/01 16:51
  • by mightymcoder