update to latest dotclear 2.26-dev changes

This commit is contained in:
Jean-Christian Paul Denis 2023-04-06 00:44:13 +02:00
parent cd5b62be60
commit 465b397504
Signed by: JcDenis
GPG Key ID: 1B5B8C5B90B6C951
6 changed files with 152 additions and 232 deletions

View File

@ -88,11 +88,11 @@ class Config extends dcNsProcess
$img_off = sprintf($img, __('not writable'), 'check-off.png');
$repo = Utils::getRepositoryDir($s->pack_repository);
$check_repo = Utils::is_writable($repo, '_.zip') ? $img_on : $img_off;
$check_first = !empty($s->pack_filename) && Utils::is_writable($repo, $s->pack_filename) ? $img_on : $img_off;
$check_second = !empty($s->secondpack_filename) && Utils::is_writable($repo, $s->secondpack_filename) ? $img_on : $img_off;
$check_repo = Utils::isWritable($repo, '_.zip') ? $img_on : $img_off;
$check_first = !empty($s->pack_filename) && Utils::isWritable($repo, $s->pack_filename) ? $img_on : $img_off;
$check_second = !empty($s->secondpack_filename) && Utils::isWritable($repo, $s->secondpack_filename) ? $img_on : $img_off;
$is_configured = Utils::is_configured(
$is_configured = Utils::isConfigured(
$repo,
$s->pack_filename,
$s->secondpack_filename

View File

@ -15,10 +15,11 @@ declare(strict_types=1);
namespace Dotclear\Plugin\pacKman;
use dcCore;
use dcModuleDefine;
use dcModules;
use files;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\File\Path;
use Dotclear\Helper\File\Zip\Unzip;
use path;
class Core
{
@ -39,12 +40,11 @@ class Core
{
$res = [];
$cache = self::getCache() . DIRECTORY_SEPARATOR;
if (!is_dir($root) || !is_readable($root)) {
return $res;
}
$files = files::scanDir($root);
$files = Files::scanDir($root);
$zip_files = [];
foreach ($files as $file) {
if (!preg_match('#(^|/)(.*?)\.zip(/|$)#', $file)) {
@ -95,7 +95,7 @@ class Core
foreach ($sandboxes as $type => $sandbox) {
try {
files::makeDir($path, true);
Files::makeDir($path, true);
// can't load twice _init.php file !
$unlink = false;
@ -118,14 +118,14 @@ class Core
if (!$sandbox->getErrors()) {
$module = $sandbox->getDefine(basename($path));
if ($module->isDefined() && $module->get('type') == $type) {
$res[$i] = $module->dump();
$res[$i]['root'] = $zip_file;
$res[$i] = $module;
$res[$i]->set('root', $zip_file);
$i++;
}
}
} catch (Exception $e) {
}
files::deltree($path);
Files::deltree($path);
}
$zip->close();
}
@ -133,20 +133,34 @@ class Core
return $res;
}
public static function pack(array $info, string $root, array $files, bool $overwrite = false, array $exclude = [], bool $nocomment = false, bool $fixnewline = false): bool
public static function pack(dcModuleDefine $define, string $root, array $files, bool $overwrite = false, array $exclude = [], bool $nocomment = false, bool $fixnewline = false): bool
{
if (!($info = self::getInfo($info))
|| !($root = self::getRoot($root))
// check define
if (!$define->isDefined()
|| empty($define->get('root'))
|| !is_dir($define->get('root'))
) {
return false;
throw new Exception(__('Failed to get module info'));
}
$exclude = self::getExclude($exclude);
// check root
$root = (string) Path::real($root);
if (!is_dir($root) || !is_writable($root)) {
throw new Exception(__('Directory is not writable'));
}
//set excluded
$exclude = self::quote_exclude(array_merge(My::EXCLUDED_FILES, $exclude));
foreach ($files as $file) {
if (!($file = self::getFile($file, $info))
|| !($dest = self::getOverwrite($overwrite, $root, $file))
) {
if (empty($file)) {
continue;
}
// check path
$path = $root . DIRECTORY_SEPARATOR . self::getFile($file, $define);
if (file_exists($path) && !$overwrite) {
// don't break loop
continue;
}
@ -158,14 +172,14 @@ class Core
if ($fixnewline) {
Zip::$fix_newline = true;
}
$zip = new Zip($dest);
$zip = new Zip($path);
foreach ($exclude as $e) {
$zip->addExclusion($e);
}
$zip->addDirectory(
(string) path::real($info['root'], false),
$info['id'],
(string) Path::real($define->get('root'), false),
$define->getId(),
true
);
@ -176,41 +190,8 @@ class Core
return true;
}
private static function getRoot(string $root): string
private static function getFile(string $file, dcModuleDefine $define): string
{
$root = (string) path::real($root);
if (!is_dir($root) || !is_writable($root)) {
throw new Exception(__('Directory is not writable'));
}
return $root;
}
private static function getInfo(array $info): array
{
if (!isset($info['root'])
|| !isset($info['id'])
|| !is_dir($info['root'])
) {
throw new Exception(__('Failed to get module info'));
}
return $info;
}
private static function getExclude(array $exclude): array
{
$exclude = array_merge(My::EXCLUDED_FILES, $exclude);
return self::quote_exclude($exclude);
}
private static function getFile(string $file, array $info): ?string
{
if (empty($file) || empty($info)) {
return null;
}
$file = str_replace(
[
'\\',
@ -222,44 +203,19 @@ class Core
],
[
'/',
$info['type'],
$info['id'],
$info['version'],
$info['author'],
$define->get('type'),
$define->getId(),
$define->get('version'),
$define->get('author'),
time(),
],
$file
);
$parts = explode('/', $file);
foreach ($parts as $i => $part) {
$parts[$i] = files::tidyFileName($part);
$parts[$i] = Files::tidyFileName($part);
}
return implode(DIRECTORY_SEPARATOR, $parts) . '.zip';
}
private static function getOverwrite(bool $overwrite, string $root, string$file): ?string
{
$path = $root . DIRECTORY_SEPARATOR . $file;
if (file_exists($path) && !$overwrite) {
// don't break loop
//throw new Exception('File already exists');
return null;
}
return $path;
}
private static function getCache(): string
{
$c = DC_TPL_CACHE . DIRECTORY_SEPARATOR . 'packman';
if (!file_exists($c)) {
@files::makeDir($c);
}
if (!is_writable($c)) {
throw new Exception(__('Failed to get temporary directory'));
}
return $c;
}
}

View File

@ -59,13 +59,13 @@ class Install extends dcNsProcess
);
while ($record->fetch()) {
if (preg_match('/^packman_(.*?)$/', $record->setting_id, $match)) {
if (preg_match('/^packman_(.*?)$/', $record->f('setting_id'), $match)) {
$cur = dcCore::app()->con->openCursor(dcCore::app()->prefix . dcNamespace::NS_TABLE_NAME);
$cur->setting_id = $match[1];
$cur->setting_ns = My::id();
$cur->setField('setting_id', $match[1]);
$cur->setField('setting_ns', My::id());
$cur->update(
"WHERE setting_id = '" . $record->setting_id . "' and setting_ns = 'pacKman' " .
'AND blog_id ' . (null === $record->blog_id ? 'IS NULL ' : ("= '" . dcCore::app()->con->escape($record->blog_id) . "' "))
"WHERE setting_id = '" . $record->f('setting_id') . "' and setting_ns = 'pacKman' " .
'AND blog_id ' . (null === $record->f('blog_id') ? 'IS NULL ' : ("= '" . dcCore::app()->con->escapeStr($record->f('blog_id')) . "' "))
);
}
}

View File

@ -19,10 +19,8 @@ use dcCore;
use dcPage;
use dcThemes;
use dcNsProcess;
/* clearbricks ns */
use files;
use http;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\Network\Http;
class Manage extends dcNsProcess
{
@ -58,13 +56,8 @@ class Manage extends dcNsProcess
$plugins = dcCore::app()->plugins;
# Rights
$is_writable = Utils::is_writable(
$dir,
$s->pack_filename
);
$is_editable = !empty($type)
&& !empty($_POST['modules'])
&& is_array($_POST['modules']);
$is_writable = Utils::isWritable($dir, $s->pack_filename);
$is_editable = !empty($type) && !empty($_POST['modules']) && is_array($_POST['modules']);
# Actions
try {
@ -77,25 +70,25 @@ class Manage extends dcNsProcess
$modules = Core::getPackages(Utils::getThemesPath());
} else {
$modules = array_merge(
Core::getPackages(dirname($dir . '/' . $s->pack_filename)),
Core::getPackages(dirname($dir . '/' . $s->secondpack_filename))
Core::getPackages(dirname($dir . DIRECTORY_SEPARATOR . $s->pack_filename)),
Core::getPackages(dirname($dir . DIRECTORY_SEPARATOR . $s->secondpack_filename))
);
}
foreach ($modules as $f) {
if (preg_match('/' . preg_quote($_REQUEST['package']) . '$/', $f['root'])
&& is_file($f['root']) && is_readable($f['root'])
foreach ($modules as $module) {
if (preg_match('/' . preg_quote($_REQUEST['package']) . '$/', $module->get('root'))
&& is_file($module->get('root')) && is_readable($module->get('root'))
) {
# --BEHAVIOR-- packmanBeforeDownloadPackage
dcCore::app()->callBehavior('packmanBeforeDownloadPackage', $f, $type);
dcCore::app()->callBehavior('packmanBeforeDownloadPackage', $module->dump(), $type);
header('Content-Type: application/zip');
header('Content-Length: ' . filesize($f['root']));
header('Content-Disposition: attachment; filename="' . basename($f['root']) . '"');
readfile($f['root']);
header('Content-Length: ' . filesize($module->get('root')));
header('Content-Disposition: attachment; filename="' . basename($module->get('root')) . '"');
readfile($module->get('root'));
# --BEHAVIOR-- packmanAfterDownloadPackage
dcCore::app()->callBehavior('packmanAfterDownloadPackage', $f, $type);
dcCore::app()->callBehavior('packmanAfterDownloadPackage', $module->dump(), $type);
exit;
}
@ -103,7 +96,7 @@ class Manage extends dcNsProcess
# Not found
header('Content-Type: text/plain');
http::head(404, 'Not Found');
Http::head(404, 'Not Found');
exit;
} elseif (!empty($action) && !$is_editable) {
dcPage::addErrorNotice(
@ -111,7 +104,7 @@ class Manage extends dcNsProcess
);
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-' . $type);
}
@ -119,16 +112,14 @@ class Manage extends dcNsProcess
# Pack
} elseif ($action == 'packup') {
foreach ($_POST['modules'] as $root => $id) {
if (!Utils::moduleExists($type, $id)) {
if (!dcCore::app()->{$type}->getDefine($id)->isDefined()) {
throw new Exception('No such module');
}
$module = Utils::getModules($type, $id);
$module['id'] = $id;
$module['type'] = $type == 'themes' ? 'theme' : 'plugin';
$module = dcCore::app()->{$type}->getDefine($id);
# --BEHAVIOR-- packmanBeforeCreatePackage
dcCore::app()->callBehavior('packmanBeforeCreatePackage', $module);
dcCore::app()->callBehavior('packmanBeforeCreatePackage', $module->dump());
Core::pack(
$module,
@ -141,7 +132,7 @@ class Manage extends dcNsProcess
);
# --BEHAVIOR-- packmanAfterCreatePackage
dcCore::app()->callBehavior('packmanAfterCreatePackage', $module);
dcCore::app()->callBehavior('packmanAfterCreatePackage', $module->dump());
}
dcPage::addSuccessNotice(
@ -149,7 +140,7 @@ class Manage extends dcNsProcess
);
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-' . $type);
}
@ -158,7 +149,7 @@ class Manage extends dcNsProcess
} elseif ($action == 'delete') {
$del_success = false;
foreach ($_POST['modules'] as $root => $id) {
if (!file_exists($root) || !files::isDeletable($root)) {
if (!file_exists($root) || !Files::isDeletable($root)) {
dcPage::addWarningNotice(sprintf(__('Undeletable file "%s"', $root)));
} else {
$del_success = true;
@ -174,7 +165,7 @@ class Manage extends dcNsProcess
}
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-repository-' . $type);
}
@ -201,7 +192,7 @@ class Manage extends dcNsProcess
);
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-repository-' . $type);
}
@ -217,7 +208,7 @@ class Manage extends dcNsProcess
foreach ($_POST['modules'] as $root => $id) {
file_put_contents(
$dest . '/' . basename($root),
$dest . DIRECTORY_SEPARATOR . basename($root),
file_get_contents($root)
);
}
@ -227,7 +218,7 @@ class Manage extends dcNsProcess
);
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-repository-' . $type);
}
@ -254,7 +245,7 @@ class Manage extends dcNsProcess
);
if (!empty($_POST['redir'])) {
http::redirect($_POST['redir']);
Http::redirect($_POST['redir']);
} else {
dcCore::app()->adminurl?->redirect('admin.plugin.' . My::id(), [], '#packman-repository-' . $type);
}
@ -276,7 +267,7 @@ class Manage extends dcNsProcess
$s = new Settings();
$dir = Utils::getRepositoryDir($s->pack_repository);
$is_configured = Utils::is_configured(
$is_configured = Utils::isConfigured(
$dir,
$s->pack_filename,
$s->secondpack_filename
@ -306,13 +297,13 @@ class Manage extends dcNsProcess
'</div>';
} else {
Utils::modules(
Utils::getModules('plugins'),
dcCore::app()->plugins->getDefines((new Settings())->hide_distrib ? ['distributed' => false] : []),
'plugins',
__('Installed plugins')
);
Utils::modules(
Utils::getModules('themes'),
dcCore::app()->themes->getDefines((new Settings())->hide_distrib ? ['distributed' => false] : []),
'themes',
__('Installed themes')
);
@ -331,8 +322,8 @@ class Manage extends dcNsProcess
Utils::repository(
array_merge(
Core::getPackages(dirname($dir . '/' . $s->pack_filename)),
Core::getPackages(dirname($dir . '/' . $s->secondpack_filename))
Core::getPackages(dirname($dir . DIRECTORY_SEPARATOR . $s->pack_filename)),
Core::getPackages(dirname($dir . DIRECTORY_SEPARATOR . $s->secondpack_filename))
),
'repository',
__('Packages repository')

View File

@ -20,14 +20,14 @@ class Uninstall
public static function init(): bool
{
self::$init = defined('DC_RC_PATH');
static::$init = defined('DC_RC_PATH');
return self::$init;
return static::$init;
}
public static function process($uninstaller): ?bool
{
if (!self::$init) {
if (!static::$init) {
return false;
}

View File

@ -14,17 +14,23 @@ declare(strict_types=1);
namespace Dotclear\Plugin\pacKman;
/* dotclear ns */
use dcCore;
use Dotclear\Helper\File\Files;
use Dotclear\Helper\File\Path;
use Dotclear\Helper\File\Zip\Unzip;
use Dotclear\Helper\File\Zip\Zip;
use Dotclear\Helper\Html\Form\{
Checkbox,
Hidden,
Label,
Para,
Select,
Submit,
Text
};
use Dotclear\Helper\Html\Html;
/* clearbricks ns */
use dt;
use files;
use form;
use html;
use path;
class Utils
{
@ -33,7 +39,7 @@ class Utils
$e = explode(PATH_SEPARATOR, DC_PLUGINS_ROOT);
$p = array_pop($e);
return (string) path::real($p);
return (string) Path::real($p);
}
public static function getThemesPath(): string
@ -41,13 +47,8 @@ class Utils
return (string) dcCore::app()->blog?->themes_path;
}
public static function is_configured(string $repo, string $file_a, string $file_b): bool
public static function isConfigured(string $repo, string $file_a, string $file_b): bool
{
if (!is_dir(DC_TPL_CACHE) || !is_writable(DC_TPL_CACHE)) {
dcCore::app()->error->add(
__('Cache directory is not writable.')
);
}
if (!is_writable($repo)) {
dcCore::app()->error->add(
__('Path to repository is not writable.')
@ -75,7 +76,7 @@ class Utils
return !dcCore::app()->error->flag();
}
public static function is_writable(string $path, string $file): bool
public static function isWritable(string $path, string $file): bool
{
return !(empty($path) || empty($file) || !is_writable(dirname($path . DIRECTORY_SEPARATOR . $file)));
}
@ -143,7 +144,7 @@ class Utils
if (empty($dir)) {
try {
$dir = DC_VAR . DIRECTORY_SEPARATOR . 'packman';
@files::makeDir($dir, true);
@Files::makeDir($dir, true);
} catch (Exception $e) {
$dir = '';
}
@ -152,35 +153,9 @@ class Utils
return $dir;
}
public static function getModules(string $type, ?string $id = null): array
{
$type = $type == 'themes' ? 'themes' : 'plugins';
$modules = array_merge(dcCore::app()->{$type}->getDisabledModules(), dcCore::app()->{$type}->getModules());
if ((new Settings())->hide_distrib) {
$modules = array_diff_key($modules, array_flip(array_values(array_merge(explode(',', DC_DISTRIB_PLUGINS), explode(',', DC_DISTRIB_THEMES)))));
}
if (empty($id)) {
return $modules;
} elseif (array_key_exists($id, $modules)) {
return $modules[$id];
}
return [];
}
public static function moduleExists(string $type, ?string $id): bool
{
$type = $type == 'themes' ? 'themes' : 'plugins';
return array_key_exists((string) $id, array_merge(dcCore::app()->{$type}->getDisabledModules(), dcCore::app()->{$type}->getModules()));
}
public static function modules(array $modules, string $type, string $title): ?bool
{
if (empty($modules) || !is_array($modules)) {
if (empty($modules)) {
return null;
}
@ -198,42 +173,41 @@ class Utils
'<th class="nowrap">' . __('Root') . '</th>' .
'</tr>';
foreach (self::sort($modules) as $id => $module) {
$i = 1;
self::sort($modules);
foreach ($modules as $module) {
echo
'<tr class="line">' .
'<td class="nowrap"><label class="classic">' .
form::checkbox(['modules[' . html::escapeHTML($module['root']) . ']'], html::escapeHTML($id)) .
html::escapeHTML($id) .
'</label></td>' .
(new Para(null, 'td'))->class('nowrap')->items([
(new Checkbox(['modules[' . Html::escapeHTML($module->get('root')) . ']', 'modules_' . $type . $i], false))->value(Html::escapeHTML($module->getId())),
(new Label(Html::escapeHTML($module->getId()), Label::OUTSIDE_LABEL_AFTER))->for('modules_' . $type . $i)->class('classic'),
])->render() .
'<td class="nowrap count">' .
html::escapeHTML($module['version']) .
Html::escapeHTML($module->get('version')) .
'</td>' .
'<td class="nowrap maximal">' .
__(html::escapeHTML($module['name'])) .
__(Html::escapeHTML($module->get('name'))) .
'</td>' .
'<td class="nowrap">' .
dirname((string) path::real($module['root'], false)) .
dirname((string) Path::real($module->get('root'), false)) .
'</td>' .
'</tr>';
$i++;
}
echo
'</table>' .
'<p class="checkboxes-helpers"></p>' .
'<p>' .
(
!empty($_REQUEST['redir']) ?
form::hidden(
['redir'],
html::escapeHTML($_REQUEST['redir'])
) : ''
) .
form::hidden(['p'], My::id()) .
form::hidden(['type'], $type) .
form::hidden(['action'], 'packup') .
'<input type="submit" name="packup" value="' .
__('Pack up selected modules') . '" />' .
dcCore::app()->formNonce() . '</p>' .
(new Para())->items([
(new Hidden(['redir'], Html::escapeHTML($_REQUEST['redir'] ?? ''))),
(new Hidden(['p'], My::id())),
(new Hidden(['type'], $type)),
(new Hidden(['action'], 'packup')),
(new Submit(['packup']))->value(__('Pack up selected modules')),
dcCore::app()->formNonce(false),
])->render() .
'</form>' .
'</div>';
@ -243,7 +217,7 @@ class Utils
public static function repository(array $modules, string $type, string $title): ?bool
{
if (empty($modules) || !is_array($modules)) {
if (empty($modules)) {
return null;
}
if (!in_array($type, ['plugins', 'themes', 'repository'])) {
@ -284,52 +258,57 @@ class Utils
'</tr>';
$dup = [];
foreach (self::sort($modules) as $module) {
if (isset($dup[$module['root']])) {
$i = 1;
self::sort($modules);
foreach ($modules as $module) {
if (isset($dup[$module->get('root')])) {
continue;
}
$dup[$module['root']] = 1;
$dup[$module->get('root')] = 1;
echo
'<tr class="line">' .
'<td class="nowrap"><label class="classic" title="' .
html::escapeHTML($module['root']) . '">' .
form::checkbox(['modules[' . html::escapeHTML($module['root']) . ']'], $module['id']) .
html::escapeHTML($module['id']) .
'</label></td>' .
(new Para(null, 'td'))->class('nowrap')->items([
(new Checkbox(['modules[' . Html::escapeHTML($module->get('root')) . ']', 'r_modules_' . $type . $i], false))->value(Html::escapeHTML($module->getId())),
(new Label(Html::escapeHTML($module->getId()), Label::OUTSIDE_LABEL_AFTER))->for('r_modules_' . $type . $i)->class('classic')->title(Html::escapeHTML($module->get('root'))),
])->render() .
'<td class="nowrap count">' .
html::escapeHTML($module['version']) .
Html::escapeHTML($module->get('version')) .
'</td>' .
'<td class="nowrap maximal">' .
__(html::escapeHTML($module['name'])) .
__(Html::escapeHTML($module->get('name'))) .
'</td>' .
'<td class="nowrap">' .
'<a class="packman-download" href="' .
dcCore::app()->adminurl?->get('admin.plugin.' . My::id(), [
'package' => basename($module['root']),
'package' => basename($module->get('root')),
'repo' => $type,
]) . '" title="' . __('Download') . '">' .
html::escapeHTML(basename($module['root'])) . '</a>' .
Html::escapeHTML(basename($module->get('root'))) . '</a>' .
'</td>' .
'<td class="nowrap">' .
html::escapeHTML(dt::str(__('%Y-%m-%d %H:%M'), (int) @filemtime($module['root']))) .
Html::escapeHTML(dt::str(__('%Y-%m-%d %H:%M'), (int) @filemtime($module->get('root')))) .
'</td>' .
'</tr>';
$i++;
}
echo
'</table>' .
'<div class="two-cols">' .
'<p class="col checkboxes-helpers"></p>' .
'<p class="col right">' . __('Selected modules action:') . ' ' .
form::combo(['action'], $combo_action) .
'<input type="submit" name="packup" value="' . __('ok') . '" />' .
form::hidden(['p'], My::id()) .
form::hidden(['tab'], 'repository') .
form::hidden(['type'], $type) .
dcCore::app()->formNonce() .
'</p>' .
(new Para())->class('col right')->items([
(new Text('', __('Selected modules action:') . ' ')),
(new Select(['action']))->items($combo_action),
(new Submit(['packup']))->value(__('ok')),
(new Hidden(['p'], My::id())),
(new Hidden(['tab'], 'repository')),
(new Hidden(['type'], $type)),
dcCore::app()->formNonce(false),
])->render() .
'</div>' .
'</form>' .
'</div>';
@ -337,15 +316,9 @@ class Utils
return true;
}
protected static function sort(array $modules): array
protected static function sort(array &$modules): void
{
$key = $ver = [];
foreach ($modules as $i => $module) {
$key[$i] = $module['id'] ?? $i;
$ver[$i] = $module['version'];
}
array_multisort($key, SORT_ASC, $ver, SORT_ASC, $modules);
return $modules;
uasort($modules, fn ($a, $b) => $a->get('version') <=> $b->get('version'));
uasort($modules, fn ($a, $b) => strtolower($a->get('id')) <=> strtolower($b->get('id')));
}
}