From 2f10fde01e7e632b268c83ae64f10aabf41e1b02 Mon Sep 17 00:00:00 2001 From: meirzamoodle Date: Wed, 26 Feb 2025 08:45:58 +0700 Subject: [PATCH] MDL-83216 AI: Provider instances ordering --- ai/classes/external/set_provider_order.php | 94 +++++++++++ ai/classes/manager.php | 152 +++++++++++++++++- .../table/aiprovider_management_table.php | 123 +++++++++++++- ai/configure_providers.php | 59 +++++++ ai/tests/external/provider_order_test.php | 63 ++++++++ ai/tests/manager_test.php | 27 +++- lang/en/ai.php | 6 +- lang/en/moodle.php | 2 + lib/classes/plugininfo/aiprovider.php | 21 +++ lib/db/services.php | 6 + lib/db/upgrade.php | 11 ++ version.php | 2 +- 12 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 ai/classes/external/set_provider_order.php create mode 100644 ai/configure_providers.php create mode 100644 ai/tests/external/provider_order_test.php diff --git a/ai/classes/external/set_provider_order.php b/ai/classes/external/set_provider_order.php new file mode 100644 index 00000000000..e17f9f26be4 --- /dev/null +++ b/ai/classes/external/set_provider_order.php @@ -0,0 +1,94 @@ +. + +namespace core_ai\external; + +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_single_structure; +use core_external\external_value; + +/** + * Web Service to control the order of a provider instance. + * + * @package core_ai + * @category external + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class set_provider_order extends external_api { + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'plugin' => new external_value(PARAM_INT, ' The provider instance ID', VALUE_REQUIRED), + 'direction' => new external_value(PARAM_INT, 'The direction to move', VALUE_REQUIRED), + ]); + } + + /** + * Set the provider instance order. + * + * @param int $providerid The provider instance ID + * @param int $direction The direction to move the provider instance + * @return array + */ + public static function execute( + int $providerid, + int $direction, + ): array { + [ + 'plugin' => $providerid, + 'direction' => $direction, + ] = self::validate_parameters(self::execute_parameters(), [ + 'plugin' => $providerid, + 'direction' => $direction, + ]); + + $context = \context_system::instance(); + self::validate_context($context); + require_capability('moodle/site:config', $context); + + $manager = \core\di::get(\core_ai\manager::class); + $aiproviders = $manager->get_provider_instances(['id' => $providerid]); + $aiprovider = reset($aiproviders); + if ($aiprovider) { + $manager->change_provider_order($providerid, $direction); + } + + $directionstring = $direction === \core\plugininfo\aiprovider::MOVE_UP + ? \core\plugininfo\aiprovider::UP + : \core\plugininfo\aiprovider::DOWN; + $message = get_string('providermoved' . $directionstring, 'ai', $aiprovider->name); + $messagetype = \core\notification::SUCCESS; + + \core\notification::add($message, $messagetype); + + return []; + } + + /** + * Describe the return structure of the external service. + * + * @return external_single_structure + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([]); + } +} diff --git a/ai/classes/manager.php b/ai/classes/manager.php index 535da567551..b6c33ef3a03 100644 --- a/ai/classes/manager.php +++ b/ai/classes/manager.php @@ -19,7 +19,7 @@ namespace core_ai; use core\exception\coding_exception; use core_ai\aiactions\base; use core_ai\aiactions\responses; - +use core\plugininfo\aiprovider as aiproviderplugin; /** * AI subsystem manager. * @@ -79,7 +79,7 @@ class manager { */ public function get_providers_for_actions(array $actions, bool $enabledonly = false): array { $providers = []; - $instances = $this->get_provider_instances(); + $instances = $this->get_sorted_providers(); foreach ($actions as $action) { $providers[$action] = []; foreach ($instances as $instance) { @@ -388,6 +388,11 @@ class manager { $id = $this->db->insert_record('ai_providers', $provider->to_record()); + // Ensure the provider instance order config gets updated if the provider is enabled. + if ($enabled) { + $this->update_provider_order($id, \core\plugininfo\aiprovider::ENABLE); + } + return $provider->with(id: $id); } @@ -410,7 +415,7 @@ class manager { * Get the provider records according to the filter. * * @param array|null $filter The filterable elements to get the records from. - * @return \stdClass[] + * @return array */ public function get_provider_records(?array $filter = null): array { return $this->db->get_records( @@ -428,7 +433,6 @@ class manager { * * @param null|array $filter The database filter to apply when fetching provider records. * @return array An array of instantiated provider objects. - * @throws \dml_exception If there is a database error during record retrieval. */ public function get_provider_instances(?array $filter = null): array { // Filter out any null values from the array (providers that couldn't be instantiated). @@ -469,7 +473,6 @@ class manager { * @param array|null $config the configuration of the provider instance to be updated. * @param array|null $actionconfig the action configuration of the provider instance to be updated. * @return provider - * @throws \dml_exception */ public function update_provider_instance( provider $provider, @@ -515,6 +518,7 @@ class manager { if (!$provider->enabled) { $provider = $provider->with(enabled: true); $this->db->update_record('ai_providers', $provider->to_record()); + $this->update_provider_order($provider->id, aiproviderplugin::ENABLE); } return $provider; @@ -536,8 +540,146 @@ class manager { $provider = $provider->with(enabled: false); $this->db->update_record('ai_providers', $provider->to_record()); } + $this->update_provider_order($provider->id, aiproviderplugin::DISABLE); } return $provider; } + + /** + * Sorts provider instances by configured order. + * + * @param array $unsorted of provider instance objects + * @return array of provider instance objects + */ + public static function sort_providers_by_order(array $unsorted): array { + $sorted = []; + $orderarray = explode(',', get_config('core_ai', 'provider_order')); + + foreach ($orderarray as $notused => $providerid) { + foreach ($unsorted as $key => $provider) { + if ($provider->id == $providerid) { + $sorted[] = $provider; + unset($unsorted[$key]); + } + } + } + + return array_merge($sorted, $unsorted); + } + + /** + * Get the configured ai providers from the manager. + * + * @return array + */ + public function get_sorted_providers(): array { + $unsorted = $this->get_provider_instances(); + $orders = $this->sort_providers_by_order($unsorted); + $sortedplugins = []; + + foreach ($orders as $order) { + $sortedplugins[$order->id] = $unsorted[$order->id]; + } + + return $sortedplugins; + } + + /** + * Change the order of the provider instance relative to other provider instances. + * + * When possible, the change will be stored into the config_log table, to let admins check when/who has modified it. + * + * @param int $providerid The provider ID. + * @param int $direction The direction to move the provider instance. Negative numbers mean up, Positive mean down. + * @return bool Whether the provider has been updated or not. + */ + public function change_provider_order(int $providerid, int $direction): bool { + $activefactors = array_keys($this->get_sorted_providers()); + $key = array_search($providerid, $activefactors); + + if ($key === false) { + return false; + } + + $movedown = ($direction === aiproviderplugin::MOVE_DOWN && $key < count($activefactors) - 1); + $moveup = ($direction === aiproviderplugin::MOVE_UP && $key >= 1); + if ($movedown || $moveup) { + $this->update_provider_order($providerid, $direction); + return true; + } + + return false; + } + + /** + * Update the provider instance order configuration. + * + * @param int $providerid The provider ID. + * @param string|int $action + * + * @throws dml_exception + */ + public function update_provider_order(int $providerid, string|int $action): void { + $order = explode(',', get_config('core_ai', 'provider_order')); + $key = array_search($providerid, $order); + + switch ($action) { + case aiproviderplugin::MOVE_UP: + if ($key >= 1) { + $fsave = $order[$key]; + $order[$key] = $order[$key - 1]; + $order[$key - 1] = $fsave; + } + break; + + case aiproviderplugin::MOVE_DOWN: + if ($key < (count($order) - 1)) { + $fsave = $order[$key]; + $order[$key] = $order[$key + 1]; + $order[$key + 1] = $fsave; + } + break; + + case aiproviderplugin::ENABLE: + if (!$key) { + $order[] = $providerid; + } + break; + + case aiproviderplugin::DISABLE: + if ($key) { + unset($order[$key]); + } + break; + } + + $this->set_provider_config(['provider_order' => implode(',', $order)], 'core_ai'); + + \core\session\manager::gc(); // Remove stale sessions. + \core_plugin_manager::reset_caches(); + } + + /** + * Sets config variable for given provider instance. + * + * @param array $data The data to set. + * @param string $plugin The plugin name. + * + * @return bool true or exception. + * @throws dml_exception + */ + public function set_provider_config(array $data, string $plugin): bool|dml_exception { + $providerconf = get_config($plugin); + foreach ($data as $key => $newvalue) { + if (empty($providerconf->$key)) { + add_to_config_log($key, null, $newvalue, $plugin); + set_config($key, $newvalue, $plugin); + } else if ($providerconf->$key != $newvalue) { + add_to_config_log($key, $providerconf->$key, $newvalue, $plugin); + set_config($key, $newvalue, $plugin); + } + } + return true; + } } diff --git a/ai/classes/table/aiprovider_management_table.php b/ai/classes/table/aiprovider_management_table.php index b6c336646d8..eb29fed9129 100644 --- a/ai/classes/table/aiprovider_management_table.php +++ b/ai/classes/table/aiprovider_management_table.php @@ -20,6 +20,7 @@ use context_system; use core_table\dynamic as dynamic_table; use flexible_table; use moodle_url; +use html_writer; /** * Table to manage AI provider plugins. @@ -34,6 +35,9 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl */ protected array $aiproviders = []; + /** @var int The number of enabled provider instances. */ + protected int $enabledprovidercount = 0; + /** * Constructor for the AI provider table. */ @@ -46,6 +50,10 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl $this->setup(); $tableclasses = $this->attributes['class'] . ' ' . $this->get_table_id(); $this->set_attribute('class', $tableclasses); + + $this->enabledprovidercount = count(array_filter($this->aiproviders, function ($provider) { + return $provider->enabled; + })); } /** @@ -135,11 +143,7 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl * @return array */ protected function get_providers(): array { - $providers = \core\di::get(\core_ai\manager::class)->get_provider_records(); - if (!empty($providers)) { - \core_collator::asort_objects_by_property($providers, 'id'); - } - return $providers; + return \core\di::get(\core_ai\manager::class)->get_sorted_providers(); } /** @@ -166,6 +170,7 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl 'name' => get_string('name'), 'provider' => get_string('provider', 'core_ai'), 'enabled' => get_string('pluginenabled', 'core_plugin'), + 'order' => get_string('order', 'core'), 'settings' => get_string('settings', 'core'), 'delete' => get_string('delete'), ]; @@ -305,4 +310,112 @@ class aiprovider_management_table extends flexible_table implements dynamic_tabl ]; return $OUTPUT->render_from_template('core_ai/admin_delete_provider', $params); } + + /** + * Get the web service method used to order provider instances. + * + * @return null|string + */ + protected function get_sortorder_service(): ?string { + return 'core_ai_set_provider_order'; + } + + /** + * Generates the HTML for the order column with up and down controls. + * + * @param \stdClass $row An object representing a row of data. + * @return string The HTML string for the order controls, or an empty string if no controls are needed. + */ + protected function col_order(\stdClass $row): string { + global $OUTPUT; + + if (!$row->enabled) { + return ''; + } + + if ($this->enabledprovidercount <= 1) { + // There is only one row. + return ''; + } + + $hasup = true; + $hasdown = true; + + if (empty($this->currentrow)) { + // This is the top row. + $hasup = false; + } + + if ($this->currentrow === ($this->enabledprovidercount - 1)) { + // This is the last row. + $hasdown = false; + } + + $dataattributes = [ + 'data-method' => $this->get_sortorder_service(), + 'data-action' => 'move', + 'data-plugin' => $row->id, + ]; + + if ($hasup) { + $upicon = html_writer::link( + $this->get_base_action_url([ + 'sesskey' => sesskey(), + 'action' => \core\plugininfo\aiprovider::UP, + 'id' => $row->id, + ]), + $OUTPUT->pix_icon( + pix: 't/up', + alt: '', + ), + array_merge($dataattributes, [ + 'data-direction' => \core\plugininfo\aiprovider::UP, + 'role' => 'button', + 'aria-label' => get_string('moveitemup', 'core', $row->name), + 'title' => get_string('moveitemup', 'core', $row->name), + 'class' => 'btn btn-link btn-icon pt-2 pl-2', + ]), + ); + } else { + $upicon = ''; + } + + if ($hasdown) { + $downicon = html_writer::link( + $this->get_base_action_url([ + 'sesskey' => sesskey(), + 'action' => \core\plugininfo\aiprovider::DOWN, + 'id' => $row->id, + ]), + $OUTPUT->pix_icon( + pix: 't/down', + alt: '', + ), + array_merge($dataattributes, [ + 'data-direction' => \core\plugininfo\aiprovider::DOWN, + 'role' => 'button', + 'aria-label' => get_string('moveitemdown', 'core', $row->name), + 'title' => get_string('moveitemdown', 'core', $row->name), + 'class' => 'btn btn-link btn-icon pt-2 pl-2', + ]), + ); + } else { + $downicon = ''; + } + + $spacer = ($hasup && $hasdown) ? $OUTPUT->spacer() : ''; + return html_writer::div($upicon . $spacer . $downicon, '', ['class' => 'w-25 d-flex justify-content-center']); + } + + /** + * Get the action URL for this table. + * + * The action URL is used to perform all actions when JS is not available. + * + * @param array $params + * @return moodle_url + */ + protected function get_base_action_url(array $params = []): moodle_url { + return new moodle_url('/ai/configure_providers.php', $params); + } } diff --git a/ai/configure_providers.php b/ai/configure_providers.php new file mode 100644 index 00000000000..7b83fc664b9 --- /dev/null +++ b/ai/configure_providers.php @@ -0,0 +1,59 @@ +. + +/** + * Configure provider instance order settings. + * + * @package core_ai + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '../../config.php'); + +require_login(autologinguest: false); +require_capability('moodle/site:config', context_system::instance()); + +require_sesskey(); + +$PAGE->set_url('/ai/configure_providers.php'); + +$action = optional_param('action', '', PARAM_ALPHA); +$id = optional_param('id', '', PARAM_INT); + +$manager = \core\di::get(\core_ai\manager::class); +$providerrecord = $manager->get_provider_records(filter: ['id' => $id]); + +$returnurl = new moodle_url('/admin/settings.php?section=aiprovider'); + +if (empty($providerrecord) || !$providerrecord) { + throw new moodle_exception('error:providernotfound', 'core_ai', $returnurl); +} + +if (empty($action) || !in_array($action, \core\plugininfo\aiprovider::get_provider_actions())) { + throw new moodle_exception('error:actionnotfound', 'core_ai', $returnurl, $action); +} + +switch ($action) { + case \core\plugininfo\aiprovider::UP: + $manager->change_provider_order($id, \core\plugininfo\aiprovider::MOVE_UP); + break; + case \core\plugininfo\aiprovider::DOWN: + $manager->change_provider_order($id, \core\plugininfo\aiprovider::MOVE_DOWN); + break; +} + +redirect($returnurl); diff --git a/ai/tests/external/provider_order_test.php b/ai/tests/external/provider_order_test.php new file mode 100644 index 00000000000..51639c8f66b --- /dev/null +++ b/ai/tests/external/provider_order_test.php @@ -0,0 +1,63 @@ +. + +namespace core_ai\external; + +/** + * Test provider order external api calls. + * + * @package core_ai + * @copyright Meirza + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_ai\external\set_provider_order + */ +final class provider_order_test extends \advanced_testcase { + /** + * Test set provider order. + */ + public function test_set_provider_order(): void { + $this->resetAfterTest(); + + // Create the provider instances. + $manager = \core\di::get(\core_ai\manager::class); + $openai = $manager->create_provider_instance( + classname: '\aiprovider_openai\provider', + name: 'openai instance', + enabled: true, // Must be set to true to activate the sort function. + config: ['data' => 'openai configurations'], + ); + $azureai = $manager->create_provider_instance( + classname: '\aiprovider_azureai\provider', + name: 'azureai instance', + enabled: true, // Must be set to true to activate the sort function. + config: ['data' => 'azureai configurations'], + ); + + $this->setAdminUser(); + + // Move the OpenAI instance to the bottom, and AzureAI will automatically move to the top. + set_provider_order::execute($openai->id, \core\plugininfo\aiprovider::MOVE_DOWN); + $providers = array_keys($manager->get_sorted_providers()); + $this->assertEquals($providers[0], $azureai->id); + $this->assertEquals($providers[1], $openai->id); + + // Move the OpenAI instance to the top, and AzureAI will automatically move to the bottom. + set_provider_order::execute($openai->id, \core\plugininfo\aiprovider::MOVE_UP); + $providers = array_keys($manager->get_sorted_providers()); + $this->assertEquals($providers[0], $openai->id); + $this->assertEquals($providers[1], $azureai->id); + } +} diff --git a/ai/tests/manager_test.php b/ai/tests/manager_test.php index 85042605a8b..fde4e81f268 100644 --- a/ai/tests/manager_test.php +++ b/ai/tests/manager_test.php @@ -280,7 +280,7 @@ final class manager_test extends \advanced_testcase { ); $config['apiendpoint'] = 'https://example.com'; - $manager->create_provider_instance( + $provider2 = $manager->create_provider_instance( classname: '\aiprovider_azureai\provider', name: 'dummy2', enabled: true, @@ -312,6 +312,31 @@ final class manager_test extends \advanced_testcase { // Assert that there is no provider for the generate text action. $this->assertCount(1, $providers[generate_text::class]); $this->assertCount(2, $providers[summarise_text::class]); + + // Ordering the provider instances. + // Re-enable the generate text action for the Openai provider. + $manager->set_action_state( + plugin: $provider1->provider, + actionbasename: generate_text::class::get_basename(), + enabled: 1, + instanceid: $provider1->id, + ); + + // Move the $provider2 to the first provider for the generate text action. + $manager->change_provider_order($provider2->id, \core\plugininfo\aiprovider::MOVE_UP); + // Get the new providers for the actions. + $providers = $manager->get_providers_for_actions($actions); + // Assert whether provider2 is the first provider and provider1 is the last provider for the generate text action. + $this->assertEquals($providers[generate_text::class][0], $provider2); + $this->assertEquals($providers[generate_text::class][1], $provider1); + + // Move the $provider2 to the last provider for the generate text action. + $manager->change_provider_order($provider2->id, \core\plugininfo\aiprovider::MOVE_DOWN); + // Get the new providers for the actions. + $providers = $manager->get_providers_for_actions($actions); + // Assert whether provider1 is the first provider and provider2 is the last provider for the generate text action. + $this->assertEquals($providers[generate_text::class][0], $provider1); + $this->assertEquals($providers[generate_text::class][1], $provider2); } /** diff --git a/lang/en/ai.php b/lang/en/ai.php index 27738d150db..0d43ff4a929 100644 --- a/lang/en/ai.php +++ b/lang/en/ai.php @@ -88,7 +88,9 @@ $string['declineaipolicy'] = 'Decline'; $string['enableglobalratelimit'] = 'Set site-wide rate limit'; $string['enableglobalratelimit_help'] = 'Limit the number of requests that the AI provider can receive across the entire site every hour.'; $string['enableuserratelimit'] = 'Set user rate limit'; -$string['enableuserratelimit_help'] = 'Limit the number of requests each user can make to the AI provider every hour.'; +$string['enableuserratelimit_help'] = 'Limit the number of requests each user can make to the OpenAI API provider every hour.'; +$string['error:actionnotfound'] = 'Action \'{$a}\' is not supported.'; +$string['error:providernotfound'] = 'The AI provider instance is not found.'; $string['globalratelimit'] = 'Maximum number of site-wide requests'; $string['globalratelimit_help'] = 'The number of site-wide requests allowed per hour.'; $string['manageaiplacements'] = 'Manage AI placements'; @@ -153,6 +155,8 @@ $string['providerinstancedeleted'] = '{$a} AI provider instance deleted.'; $string['providerinstancedeletefailed'] = 'Cannot delete the {$a} AI provider instance. The provider is either in use or there is a database issue. Check if the provider is active or contact your database administrator for help.'; $string['providerinstancedisablefailed'] = 'Cannot disable the AI provider instance. The provider is either in use or there is a database issue. Check if the provider is active or contact your database administrator for help.'; $string['providerinstanceupdated'] = '{$a} AI provider instance updated.'; +$string['providermoveddown'] = '{$a} moved down'; +$string['providermovedup'] = '{$a} moved up'; $string['providername'] = 'Name for instance'; $string['providers'] = 'Providers'; $string['providersettings'] = 'Settings'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 9e169d4dbea..de2625673e2 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1446,6 +1446,8 @@ $string['movedown'] = 'Move down'; $string['movefilestohere'] = 'Move files to here'; $string['movefull'] = 'Move {$a} to this location'; $string['movehere'] = 'Move to here'; +$string['moveitemdown'] = 'Move {$a} down'; +$string['moveitemup'] = 'Move {$a} up'; $string['moveleft'] = 'Move left'; $string['moremenu'] = 'More'; $string['moreactions'] = 'More actions'; diff --git a/lib/classes/plugininfo/aiprovider.php b/lib/classes/plugininfo/aiprovider.php index 4e61ca0a20f..00564d5a82e 100644 --- a/lib/classes/plugininfo/aiprovider.php +++ b/lib/classes/plugininfo/aiprovider.php @@ -27,6 +27,18 @@ use moodle_url; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class aiprovider extends base { + /** @var string Enable a plugin */ + public const ENABLE = 'enable'; + + /** @var string Disable a plugin */ + public const DISABLE = 'disable'; + + /** @var string Move a plugin up in the plugin order */ + public const UP = 'up'; + + /** @var string Move a plugin down in the plugin order */ + public const DOWN = 'down'; + #[\Override] public function is_uninstall_allowed(): bool { return true; @@ -65,4 +77,13 @@ class aiprovider extends base { } return $enabled; } + + /** + * Returns the list of available actions with provider. + * + * @return array + */ + public static function get_provider_actions(): array { + return [self::UP, self::DOWN]; + } } diff --git a/lib/db/services.php b/lib/db/services.php index b7f30cc98fc..cf30537629a 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -3292,6 +3292,12 @@ $functions = array( 'type' => 'write', 'ajax' => true, ], + 'core_ai_set_provider_order' => [ + 'classname' => \core_ai\external\set_provider_order::class, + 'description' => 'Set the order of a provider', + 'type' => 'write', + 'ajax' => true, + ], 'core_sms_set_gateway_status' => [ 'classname' => 'core_sms\external\sms_gateway_status', 'description' => 'Set the sms gateway status', diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 1a0cf7a874a..741c8cee9fb 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1464,5 +1464,16 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025022100.02); } + if ($oldversion < 2025030400.01) { + $providers = $DB->get_records('ai_providers', ['enabled' => 1]); + // Formatting the value. + $value = ','. implode(',', array_column($providers, 'id')); + // Create the order config setting. + set_config('provider_order', $value, 'core_ai'); + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025030400.01); + } + return true; } diff --git a/version.php b/version.php index feabfa1be16..c8bfd81c831 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025030400.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025030400.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.0dev+ (Build: 20250304)'; // Human-friendly version name -- 2.43.0