From bbdbfe7ab0cec1c9dd8d4def71748fc8c76750d9 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 12 Jan 2026 00:00:00 +0000 Subject: [PATCH 01/26] Allowed to store custom modules and themes in custom/. --- .gitignore | 6 +- application/config/application.config.php | 1 + .../src/Service/ModuleManagerFactory.php | 102 ++++++++------ .../src/Service/ThemeManagerFactory.php | 133 ++++++++++-------- application/src/Site/Theme/Theme.php | 30 +++- 5 files changed, 169 insertions(+), 103 deletions(-) diff --git a/.gitignore b/.gitignore index 70316a2746..a8f89267dd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,10 @@ /logs/sql.log /logs/application.log /files/ -/modules/ -/themes/ +/modules/* +!/modules/custom/ +/themes/* +!/themes/custom/ /application/test/config/database.ini /application/test/.phpunit.result.cache /application/asset/css/font-awesome/ diff --git a/application/config/application.config.php b/application/config/application.config.php index 1cdd31a32c..518c89e11a 100644 --- a/application/config/application.config.php +++ b/application/config/application.config.php @@ -31,6 +31,7 @@ 'module_listener_options' => [ 'module_paths' => [ 'Omeka' => OMEKA_PATH . '/application', + OMEKA_PATH . '/modules/custom', OMEKA_PATH . '/modules', ], 'config_glob_paths' => [ diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index cf483470d9..35aaee2fb7 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -31,50 +31,68 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $connection = $serviceLocator->get('Omeka\Connection'); // Get all modules from the filesystem. - foreach (new DirectoryIterator(OMEKA_PATH . '/modules') as $dir) { - - // Module must be a directory - if (!$dir->isDir() || $dir->isDot()) { - continue; - } - - $module = $manager->registerModule($dir->getBasename()); - - // Module directory must contain config/module.ini - $iniFile = new SplFileInfo($dir->getPathname() . '/config/module.ini'); - if (!$iniFile->isReadable() || !$iniFile->isFile()) { - $module->setState(ModuleManager::STATE_INVALID_INI); + // Scan custom directory first so custom modules take precedence. + $modulePaths = [ + OMEKA_PATH . '/modules/custom', + OMEKA_PATH . '/modules', + ]; + $registered = []; + foreach ($modulePaths as $modulePath) { + if (!is_dir($modulePath)) { continue; } - - $ini = $iniReader->fromFile($iniFile->getRealPath()); - - // The INI configuration must be under the [info] header. - if (!isset($ini['info'])) { - $module->setState(ModuleManager::STATE_INVALID_INI); - continue; - } - - $module->setIni($ini['info']); - - // Module INI must be valid - if (!$manager->iniIsValid($module)) { - $module->setState(ModuleManager::STATE_INVALID_INI); - continue; - } - - // Module directory must contain Module.php - $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); - if (!$moduleFile->isReadable() || !$moduleFile->isFile()) { - $module->setState(ModuleManager::STATE_INVALID_MODULE); - continue; - } - $module->setModuleFilePath($moduleFile->getRealPath()); - - $omekaConstraint = $module->getIni('omeka_version_constraint'); - if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { - $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION); - continue; + foreach (new DirectoryIterator($modulePath) as $dir) { + + // Module must be a directory + if (!$dir->isDir() || $dir->isDot()) { + continue; + } + + // Skip if module already registered (custom takes precedence). + $moduleId = $dir->getBasename(); + if (isset($registered[$moduleId])) { + continue; + } + $registered[$moduleId] = true; + + $module = $manager->registerModule($moduleId); + + // Module directory must contain config/module.ini + $iniFile = new SplFileInfo($dir->getPathname() . '/config/module.ini'); + if (!$iniFile->isReadable() || !$iniFile->isFile()) { + $module->setState(ModuleManager::STATE_INVALID_INI); + continue; + } + + $ini = $iniReader->fromFile($iniFile->getRealPath()); + + // The INI configuration must be under the [info] header. + if (!isset($ini['info'])) { + $module->setState(ModuleManager::STATE_INVALID_INI); + continue; + } + + $module->setIni($ini['info']); + + // Module INI must be valid + if (!$manager->iniIsValid($module)) { + $module->setState(ModuleManager::STATE_INVALID_INI); + continue; + } + + // Module directory must contain Module.php + $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); + if (!$moduleFile->isReadable() || !$moduleFile->isFile()) { + $module->setState(ModuleManager::STATE_INVALID_MODULE); + continue; + } + $module->setModuleFilePath($moduleFile->getRealPath()); + + $omekaConstraint = $module->getIni('omeka_version_constraint'); + if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { + $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION); + continue; + } } } diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 6252eea7ed..13cf0ad1e6 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -23,66 +23,85 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $iniReader = new IniReader; // Get all themes from the filesystem. - foreach (new DirectoryIterator(OMEKA_PATH . '/themes') as $dir) { - - // Theme must be a directory - if (!$dir->isDir() || $dir->isDot()) { - continue; - } - - $theme = $manager->registerTheme($dir->getBasename()); - - // Theme directory must contain config/module.ini - $iniFile = new SplFileInfo($dir->getPathname() . '/config/theme.ini'); - if (!$iniFile->isReadable() || !$iniFile->isFile()) { - $theme->setState(ThemeManager::STATE_INVALID_INI); - continue; - } - - $ini = $iniReader->fromFile($iniFile->getRealPath()); - - // The INI configuration must be under the [info] header. - if (!isset($ini['info'])) { - $theme->setState(ThemeManager::STATE_INVALID_INI); - continue; - } - $configSpec = []; - if (isset($ini['config'])) { - $configSpec = $ini['config']; - } - - $theme->setIni($ini['info']); - $theme->setConfigSpec($configSpec); - - // Theme INI must be valid - if (!$manager->iniIsValid($theme)) { - $theme->setState(ThemeManager::STATE_INVALID_INI); - continue; - } - - $omekaConstraint = $theme->getIni('omeka_version_constraint'); - if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { - $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION); + // Scan custom directory first so custom themes take precedence. + $themePaths = [ + 'themes/custom' => OMEKA_PATH . '/themes/custom', + 'themes' => OMEKA_PATH . '/themes', + ]; + $registered = []; + foreach ($themePaths as $basePath => $themePath) { + if (!is_dir($themePath)) { continue; } - - $theme->setState(ThemeManager::STATE_ACTIVE); - - // Inject module templates, with priority to theme templates. - // Take care of merge with duplicate template keys. - if (count($modulePageTemplates)) { - $configSpec['page_templates'] = empty($configSpec['page_templates']) - ? $modulePageTemplates - : array_replace($modulePageTemplates, $configSpec['page_templates']); - } - if (count($moduleBlockTemplates)) { - $configSpec['block_templates'] = empty($configSpec['block_templates']) - ? $moduleBlockTemplates - // Array_merge_recursive() converts duplicate keys to array. - // Array_map() removes keys. - : array_replace_recursive($moduleBlockTemplates, $configSpec['block_templates']); + foreach (new DirectoryIterator($themePath) as $dir) { + + // Theme must be a directory + if (!$dir->isDir() || $dir->isDot()) { + continue; + } + + // Skip if theme already registered (custom takes precedence). + $themeId = $dir->getBasename(); + if (isset($registered[$themeId])) { + continue; + } + $registered[$themeId] = true; + + $theme = $manager->registerTheme($themeId); + $theme->setBasePath($basePath); + + // Theme directory must contain config/theme.ini + $iniFile = new SplFileInfo($dir->getPathname() . '/config/theme.ini'); + if (!$iniFile->isReadable() || !$iniFile->isFile()) { + $theme->setState(ThemeManager::STATE_INVALID_INI); + continue; + } + + $ini = $iniReader->fromFile($iniFile->getRealPath()); + + // The INI configuration must be under the [info] header. + if (!isset($ini['info'])) { + $theme->setState(ThemeManager::STATE_INVALID_INI); + continue; + } + $configSpec = []; + if (isset($ini['config'])) { + $configSpec = $ini['config']; + } + + $theme->setIni($ini['info']); + $theme->setConfigSpec($configSpec); + + // Theme INI must be valid + if (!$manager->iniIsValid($theme)) { + $theme->setState(ThemeManager::STATE_INVALID_INI); + continue; + } + + $omekaConstraint = $theme->getIni('omeka_version_constraint'); + if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { + $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION); + continue; + } + + $theme->setState(ThemeManager::STATE_ACTIVE); + + // Inject module templates, with priority to theme templates. + // Take care of merge with duplicate template keys. + if (count($modulePageTemplates)) { + $configSpec['page_templates'] = empty($configSpec['page_templates']) + ? $modulePageTemplates + : array_replace($modulePageTemplates, $configSpec['page_templates']); + } + if (count($moduleBlockTemplates)) { + $configSpec['block_templates'] = empty($configSpec['block_templates']) + ? $moduleBlockTemplates + // Array_merge_recursive() converts duplicate keys to array. + // Array_map() removes keys. + : array_replace_recursive($moduleBlockTemplates, $configSpec['block_templates']); + } + $theme->setConfigSpec($configSpec); } - $theme->setConfigSpec($configSpec); } // Note that, unlike the ModuleManagerFactory, this does not register diff --git a/application/src/Site/Theme/Theme.php b/application/src/Site/Theme/Theme.php index 05c54703d7..672784bbd6 100644 --- a/application/src/Site/Theme/Theme.php +++ b/application/src/Site/Theme/Theme.php @@ -8,6 +8,11 @@ class Theme */ protected $id; + /** + * @var string Relative path from OMEKA_PATH (e.g., 'themes/custom' or 'themes') + */ + protected $basePath = 'themes'; + /** * @var string */ @@ -43,6 +48,26 @@ public function getId() return $this->id; } + /** + * Set the base path for this theme (relative to OMEKA_PATH). + * + * @param string $basePath e.g., 'themes/custom' or 'themes' + */ + public function setBasePath($basePath) + { + $this->basePath = $basePath; + } + + /** + * Get the base path for this theme (relative to OMEKA_PATH). + * + * @return string + */ + public function getBasePath() + { + return $this->basePath; + } + /** * Set the theme state. * @@ -123,9 +148,10 @@ public function getSettingsKey() public function getThumbnail($key = null) { if ($key) { + // For other themes, assume default path (no basePath info available). return '/themes/' . $key . "/theme.jpg"; } - return '/themes/' . $this->id . "/theme.jpg"; + return '/' . $this->basePath . '/' . $this->id . "/theme.jpg"; } /** @@ -162,7 +188,7 @@ public function isConfigurableResourcePageBlocks() */ public function getPath(string ...$subsegments): string { - $segments = array_merge([OMEKA_PATH, 'themes', $this->id], $subsegments); + $segments = array_merge([OMEKA_PATH, $this->basePath, $this->id], $subsegments); return implode('/', $segments); } } From 4d047c0d6e178a0175615daaf78b2baaf69b85a2 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 02/26] Used addons/modules and addons/themes for composer. --- .gitignore | 10 ++++++---- .php-cs-fixer.dist.php | 1 + addons/modules/.gitkeep | 0 addons/themes/.gitkeep | 0 application/config/application.config.php | 2 +- .../composer-addon-installer/composer.json | 8 ++++++-- .../src/AddonInstaller.php | 19 ++++++++++--------- application/src/Module/AbstractModule.php | 2 +- .../src/Service/ModuleManagerFactory.php | 6 +++--- .../src/Service/ThemeManagerFactory.php | 6 +++--- composer.lock | 12 +++++++----- 11 files changed, 38 insertions(+), 28 deletions(-) create mode 100644 addons/modules/.gitkeep create mode 100644 addons/themes/.gitkeep diff --git a/.gitignore b/.gitignore index a8f89267dd..922bafc779 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,12 @@ /logs/sql.log /logs/application.log /files/ -/modules/* -!/modules/custom/ -/themes/* -!/themes/custom/ +/modules/ +/themes/ +/addons/modules/* +!/addons/modules/.gitkeep +/addons/themes/* +!/addons/themes/.gitkeep /application/test/config/database.ini /application/test/.phpunit.result.cache /application/asset/css/font-awesome/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 683c2201c5..203041a7d8 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -52,6 +52,7 @@ ->exclude('application/data/overrides') ->exclude('config') ->exclude('files') + ->exclude('addons') ->exclude('modules') ->exclude('node_modules') ->exclude('themes') diff --git a/addons/modules/.gitkeep b/addons/modules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/addons/themes/.gitkeep b/addons/themes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/application/config/application.config.php b/application/config/application.config.php index 518c89e11a..69444d8135 100644 --- a/application/config/application.config.php +++ b/application/config/application.config.php @@ -31,8 +31,8 @@ 'module_listener_options' => [ 'module_paths' => [ 'Omeka' => OMEKA_PATH . '/application', - OMEKA_PATH . '/modules/custom', OMEKA_PATH . '/modules', + OMEKA_PATH . '/addons/modules', ], 'config_glob_paths' => [ OMEKA_PATH . '/config/local.config.php', diff --git a/application/data/composer-addon-installer/composer.json b/application/data/composer-addon-installer/composer.json index d3f7ccc9e6..bb03d5209f 100644 --- a/application/data/composer-addon-installer/composer.json +++ b/application/data/composer-addon-installer/composer.json @@ -1,13 +1,17 @@ { "name": "omeka/composer-addon-installer", - "version": "2.0", + "version": "3.0", + "description": "Composer plugin to install Omeka S modules and themes into addons/modules/ and addons/themes/.", "type": "composer-plugin", "license": "GPL-3.0", "require": { + "php": ">=8.1", "composer-plugin-api": "^2.0" }, "autoload": { - "psr-4": {"Omeka\\Composer\\": "src/"} + "psr-4": { + "Omeka\\Composer\\": "src/" + } }, "extra": { "class": "Omeka\\Composer\\AddonInstallerPlugin" diff --git a/application/data/composer-addon-installer/src/AddonInstaller.php b/application/data/composer-addon-installer/src/AddonInstaller.php index b77480f4fc..8b3fb3a9d1 100644 --- a/application/data/composer-addon-installer/src/AddonInstaller.php +++ b/application/data/composer-addon-installer/src/AddonInstaller.php @@ -12,7 +12,7 @@ class AddonInstaller extends LibraryInstaller * * @return string */ - public static function getInstallName(PackageInterface $package) + public static function getInstallName(PackageInterface $package): string { $extra = $package->getExtra(); if (isset($extra['install-name'])) { @@ -22,28 +22,29 @@ public static function getInstallName(PackageInterface $package) $packageName = $package->getPrettyName(); $slashPos = strpos($packageName, '/'); if ($slashPos === false) { - throw new \InvalidArgumentException('Addon package names must contain a slash'); + throw new \InvalidArgumentException('Addon package names must contain a slash'); // @translate } $addonName = substr($packageName, $slashPos + 1); return $addonName; } - public function getInstallPath(PackageInterface $package) + public function getInstallPath(PackageInterface $package): string { $addonName = static::getInstallName($package); switch ($package->getType()) { - case 'omeka-s-theme': - return 'themes/' . $addonName; case 'omeka-s-module': - return 'modules/' . $addonName; + return 'addons/modules/' . $addonName; + case 'omeka-s-theme': + return 'addons/themes/' . $addonName; default: - throw new \InvalidArgumentException('Invalid Omeka S addon package type'); + throw new \InvalidArgumentException('Invalid Omeka S addon package type'); // @translate } } - public function supports($packageType) + public function supports($packageType): bool { - return in_array($packageType, ['omeka-s-theme', 'omeka-s-module']); + return $packageType === 'omeka-s-module' + || $packageType === 'omeka-s-theme'; } } diff --git a/application/src/Module/AbstractModule.php b/application/src/Module/AbstractModule.php index 34d7103460..c602e8d856 100644 --- a/application/src/Module/AbstractModule.php +++ b/application/src/Module/AbstractModule.php @@ -125,7 +125,7 @@ public function getAutoloaderConfig() return; } - $autoloadPath = sprintf('%1$s/modules/%2$s/src', OMEKA_PATH, $namespace); + $autoloadPath = dirname($classInfo->getFileName()) . '/src'; return [ 'Laminas\Loader\StandardAutoloader' => [ 'namespaces' => [ diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index 35aaee2fb7..f13f79cde8 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -31,10 +31,10 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $connection = $serviceLocator->get('Omeka\Connection'); // Get all modules from the filesystem. - // Scan custom directory first so custom modules take precedence. + // Scan local modules first so they take precedence over addons. $modulePaths = [ - OMEKA_PATH . '/modules/custom', OMEKA_PATH . '/modules', + OMEKA_PATH . '/addons/modules', ]; $registered = []; foreach ($modulePaths as $modulePath) { @@ -48,7 +48,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar continue; } - // Skip if module already registered (custom takes precedence). + // Skip if module already registered (local takes precedence). $moduleId = $dir->getBasename(); if (isset($registered[$moduleId])) { continue; diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 13cf0ad1e6..3d998cd4ce 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -23,10 +23,10 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $iniReader = new IniReader; // Get all themes from the filesystem. - // Scan custom directory first so custom themes take precedence. + // Scan local themes first so they take precedence over addons. $themePaths = [ - 'themes/custom' => OMEKA_PATH . '/themes/custom', 'themes' => OMEKA_PATH . '/themes', + 'addons/themes' => OMEKA_PATH . '/addons/themes', ]; $registered = []; foreach ($themePaths as $basePath => $themePath) { @@ -40,7 +40,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar continue; } - // Skip if theme already registered (custom takes precedence). + // Skip if theme already registered (local takes precedence). $themeId = $dir->getBasename(); if (isset($registered[$themeId])) { continue; diff --git a/composer.lock b/composer.lock index 9feb88b713..9d6193f672 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "faea4c0ff76ac9433b741b0d9d5e4b2b", + "content-hash": "24bc0316b1cf81747ad40ffde6c5da7e", "packages": [ { "name": "beberlei/doctrineextensions", @@ -4227,14 +4227,15 @@ }, { "name": "omeka/composer-addon-installer", - "version": "2.0", + "version": "3.0", "dist": { "type": "path", "url": "application/data/composer-addon-installer", - "reference": "7397409eb65cbdf0000ad73a1c0cc38bae53fff1" + "reference": "8ec3fb6ecaed6f9d5896a549c6b9b8524c6ed3bb" }, "require": { - "composer-plugin-api": "^2.0" + "composer-plugin-api": "^2.0", + "php": ">=8.1" }, "type": "composer-plugin", "extra": { @@ -4248,6 +4249,7 @@ "license": [ "GPL-3.0" ], + "description": "Composer plugin to install Omeka S modules and themes into addons/modules/ and addons/themes/.", "transport-options": { "relative": true } @@ -9355,5 +9357,5 @@ "platform-overrides": { "php": "8.1" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From 429ed312443d5b41ff986387e708794fdd88523a Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 03/26] Replaced ini by composer, with fallback module.ini. --- application/src/Module/InfoReader.php | 340 ++++++++++++++++++ .../src/Service/ModuleManagerFactory.php | 26 +- .../src/Service/ThemeManagerFactory.php | 39 +- 3 files changed, 366 insertions(+), 39 deletions(-) create mode 100644 application/src/Module/InfoReader.php diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php new file mode 100644 index 0000000000..cfd16eb2ed --- /dev/null +++ b/application/src/Module/InfoReader.php @@ -0,0 +1,340 @@ + module.ini/theme.ini > defaults. + */ +class InfoReader +{ + /** + * Mapping from composer.json to ini keys. + * + * @var array + */ + protected $composerToIniMap = [ + // composer.json key => ini key + 'description' => 'description', + 'license' => 'license', + // theme_link is managed below. + 'homepage' => 'module_link', + 'version' => 'version', + ]; + + /** + * Mapping from composer.json extra keys to ini keys. + * + * @var array + */ + protected $extraToIniMap = [ + 'label' => 'name', + 'addon-version' => 'version', + 'configurable' => 'configurable', + 'omeka-version-constraint' => 'omeka_version_constraint', + ]; + + /** + * Read info from composer.json and ini file. + * + * @param string $path The module/theme directory path + * @param string $type 'module' or 'theme' + * @return array|null Returns merged info array, or null if no valid source found + */ + public function read(string $path, string $type = 'module'): ?array + { + $composerInfo = $this->readComposerJson($path); + $iniInfo = $this->readIniFile($path, $type); + + // At least one source must exist. + if ($composerInfo === null && $iniInfo === null) { + return null; + } + + return $this->merge($composerInfo, $iniInfo, $path); + } + + /** + * Check if the info is valid (has required fields). + */ + public function isValid(?array $info): bool + { + if (!$info) { + return false; + } + + // Required field: name only. + // Version (or extra/addon-version) can be derived from composer. + if (empty($info['name'])) { + return false; + } + + return true; + } + + /** + * Read and parse composer.json file. + */ + protected function readComposerJson(string $path): ?array + { + $file = $path . '/composer.json'; + if (!is_file($file) || !is_readable($file)) { + return null; + } + + $content = file_get_contents($file); + $json = json_decode($content, true); + return is_array($json) + ? $json + : null; + } + + /** + * Read and parse ini file. + * + * @param string $type "module" or "theme". + */ + protected function readIniFile(string $path, string $type): ?array + { + $file = $path . '/config/' . $type . '.ini'; + if (!is_file($file) || !is_readable($file)) { + return null; + } + + $iniReader = new IniReader(); + try { + $ini = $iniReader->fromFile($file); + } catch (\Exception $e) { + return null; + } + + return $ini['info'] ?? null; + } + + /** + * Merge composer.json and ini info, with composer.json taking precedence. + */ + protected function merge(?array $composerJson, ?array $iniInfo, string $path): array + { + $info = []; + + // Start with ini info as base. + if ($iniInfo) { + $info = $iniInfo; + } + + // If no composer.json, return ini info with defaults + if (!$composerJson) { + return $this->applyDefaults($info, $path); + } + + // Map standard composer.json fields. + foreach ($this->composerToIniMap as $composerKey => $iniKey) { + if (isset($composerJson[$composerKey]) && $composerJson[$composerKey] !== '') { + $info[$iniKey] = $composerJson[$composerKey]; + } + } + + // Map extra fields. + $extra = $composerJson['extra'] ?? []; + foreach ($this->extraToIniMap as $extraKey => $iniKey) { + if (isset($extra[$extraKey])) { + $info[$iniKey] = $extra[$extraKey]; + } + } + + // Map keywords to tags. + if (isset($composerJson['keywords']) && is_array($composerJson['keywords'])) { + // Filter out generic keywords. + $keywords = array_filter($composerJson['keywords'], function ($keyword) { + return !in_array(strtolower($keyword), [ + 'omeka', + 'omeka s', + 'omeka-s', + 'omeka s module', + 'omeka module', + 'module', + 'omeka s theme', + 'omeka theme', + 'theme', + ]); + }); + if (count($keywords)) { + $info['tags'] = implode(', ', $keywords); + } + } + + // Map authors. + if (isset($composerJson['authors']) && is_array($composerJson['authors']) && count($composerJson['authors'])) { + $firstAuthor = $composerJson['authors'][0]; + if (isset($firstAuthor['name']) && !isset($info['author'])) { + $info['author'] = $firstAuthor['name']; + } + if (isset($firstAuthor['homepage']) && !isset($info['author_link'])) { + $info['author_link'] = $firstAuthor['homepage']; + } + } + + // Map support. + if (isset($composerJson['support']) && is_array($composerJson['support'])) { + if (isset($composerJson['support']['issues']) && !isset($info['support_link'])) { + $info['support_link'] = $composerJson['support']['issues']; + } + } + + // Specific: theme_link for themes. + if (isset($composerJson['homepage']) && !isset($info['theme_link'])) { + $info['theme_link'] = $composerJson['homepage']; + } + + return $this->applyDefaults($info, $path, $composerJson); + } + + /** + * Apply default values for missing fields. + */ + protected function applyDefaults(array $info, string $path, ?array $composerJson = null): array + { + $dirName = basename($path); + + // Default name: use directory name if not set. + if (empty($info['name'])) { + // Try to get a nice name from composer project name + if ($composerJson !== null && isset($composerJson['name'])) { + $info['name'] = $this->projectNameToLabel($composerJson['name']); + } else { + // Convert CamelCase to "Camel Case" + $info['name'] = preg_replace('/([a-z])([A-Z])/', '$1 $2', $dirName); + } + } + + // Default version: try composer installed data, then fallback. + if (empty($info['version'])) { + $composerVersion = $this->getVersionFromComposerInstalled($composerJson); + if ($composerVersion) { + $info['version'] = $composerVersion; + } else { + // According to composer, default version is 1.0.0. + $info['version'] = '1.0.0'; + } + } + + // Default configurable is false. + $info['configurable'] = !empty($info['configurable']); + + return $info; + } + + /** + * Try to get version from Composer's installed.json. + */ + protected function getVersionFromComposerInstalled(?array $composerJson): ?string + { + if ($composerJson === null || !isset($composerJson['name'])) { + return null; + } + + $packageName = $composerJson['name']; + $installedFile = OMEKA_PATH . '/vendor/composer/installed.json'; + + if (!is_file($installedFile) || !is_readable($installedFile)) { + return null; + } + + $content = file_get_contents($installedFile); + $installed = json_decode($content, true); + + if (!$installed) { + return null; + } + + // Composer 2.x format: packages are in "packages" key. + $packages = $installed['packages'] ?? $installed; + + foreach ($packages as $package) { + if (isset($package['name']) && $package['name'] === $packageName) { + $version = $package['version'] ?? null; + if ($version) { + // Remove 'v' prefix if present. + return ltrim($version, 'vV'); + } + } + } + + return null; + } + + /** + * Get the installer name (directory name) from composer.json. + */ + public function getInstallerName(string $path): ?string + { + $composerJson = $this->readComposerJson($path); + if ($composerJson === null) { + return null; + } + + // First check extra.installer-name. + if (isset($composerJson['extra']['installer-name'])) { + return $composerJson['extra']['installer-name']; + } + + // Fall back to computed directory name from project name. + if (isset($composerJson['name'])) { + return $this->projectNameToDirectory($composerJson['name']); + } + + return null; + } + + /** + * Convert a composer project name to a human-readable label. + * + * Removes common prefixes and suffixes like AddonInstaller::inflect*. + * + * Example: "daniel-km/omeka-s-module-easy-admin" => "Easy Admin" + */ + public function projectNameToLabel(string $projectName): string + { + // Extract composer project name. + $parts = explode('/', $projectName); + $project = end($parts); + + // Remove common prefixes and suffixes. + $project = preg_replace('/^(omeka-?s?-?)?(module-|theme-)?/i', '', $project); + $project = preg_replace('/(-module|-theme)?(-omeka-?s?)?$/i', '', $project); + + // Convert kebab-case to Title Case. + $words = explode('-', $project); + $words = array_map('ucfirst', $words); + + return implode(' ', $words); + } + + /** + * Convert a composer project name to a directory name. + * + * Removes common prefixes and suffixes like AddonInstaller::inflect*. + * + * Example: "daniel-km/omeka-s-module-easy-admin" => "EasyAdmin" + */ + public function projectNameToDirectory(string $projectName): string + { + // Extract composer project name. + $parts = explode('/', $projectName); + $project = end($parts); + + // Remove common prefixes and suffixes. + $project = preg_replace('/^(omeka-?s?-?)?(module-|theme-)?/i', '', $project); + $project = preg_replace('/(-module|-theme)?(-omeka-?s?)?$/i', '', $project); + + // Convert kebab-case to PascalCase. + $words = explode('-', $project); + $words = array_map('ucfirst', $words); + + return implode('', $words); + } +} diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index f13f79cde8..fe090b838e 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -8,9 +8,9 @@ use Composer\Semver\Semver; use Interop\Container\ContainerInterface; use Omeka\Module as CoreModule; +use Omeka\Module\InfoReader; use Omeka\Module\Manager as ModuleManager; use SplFileInfo; -use Laminas\Config\Reader\Ini as IniReader; use Laminas\ServiceManager\Factory\FactoryInterface; /** @@ -27,7 +27,7 @@ class ModuleManagerFactory implements FactoryInterface public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?array $options = null) { $manager = new ModuleManager($serviceLocator); - $iniReader = new IniReader; + $infoReader = new InfoReader(); $connection = $serviceLocator->get('Omeka\Connection'); // Get all modules from the filesystem. @@ -57,28 +57,16 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $module = $manager->registerModule($moduleId); - // Module directory must contain config/module.ini - $iniFile = new SplFileInfo($dir->getPathname() . '/config/module.ini'); - if (!$iniFile->isReadable() || !$iniFile->isFile()) { - $module->setState(ModuleManager::STATE_INVALID_INI); - continue; - } - - $ini = $iniReader->fromFile($iniFile->getRealPath()); + // Read info from composer.json and/or config/module.ini + $info = $infoReader->read($dir->getPathname(), 'module'); - // The INI configuration must be under the [info] header. - if (!isset($ini['info'])) { + // Module must have valid info (from composer.json or module.ini) + if (!$infoReader->isValid($info)) { $module->setState(ModuleManager::STATE_INVALID_INI); continue; } - $module->setIni($ini['info']); - - // Module INI must be valid - if (!$manager->iniIsValid($module)) { - $module->setState(ModuleManager::STATE_INVALID_INI); - continue; - } + $module->setIni($info); // Module directory must contain Module.php $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 3d998cd4ce..3efeb084d9 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -2,9 +2,9 @@ namespace Omeka\Service; use DirectoryIterator; -use SplFileInfo; use Composer\Semver\Semver; use Omeka\Module as CoreModule; +use Omeka\Module\InfoReader; use Omeka\Site\Theme\Manager as ThemeManager; use Laminas\Config\Reader\Ini as IniReader; use Laminas\ServiceManager\Factory\FactoryInterface; @@ -20,6 +20,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $moduleBlockTemplates = $config['block_templates']; $manager = new ThemeManager; + $infoReader = new InfoReader(); $iniReader = new IniReader; // Get all themes from the filesystem. @@ -50,34 +51,32 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $theme = $manager->registerTheme($themeId); $theme->setBasePath($basePath); - // Theme directory must contain config/theme.ini - $iniFile = new SplFileInfo($dir->getPathname() . '/config/theme.ini'); - if (!$iniFile->isReadable() || !$iniFile->isFile()) { - $theme->setState(ThemeManager::STATE_INVALID_INI); - continue; - } + // Read info from composer.json and/or config/theme.ini + $info = $infoReader->read($dir->getPathname(), 'theme'); - $ini = $iniReader->fromFile($iniFile->getRealPath()); - - // The INI configuration must be under the [info] header. - if (!isset($ini['info'])) { + // Theme must have valid info (from composer.json or theme.ini) + if (!$infoReader->isValid($info)) { $theme->setState(ThemeManager::STATE_INVALID_INI); continue; } + + // Read config spec from theme.ini [config] section if present $configSpec = []; - if (isset($ini['config'])) { - $configSpec = $ini['config']; + $iniFile = $dir->getPathname() . '/config/theme.ini'; + if (is_file($iniFile) && is_readable($iniFile)) { + try { + $ini = $iniReader->fromFile($iniFile); + if (isset($ini['config'])) { + $configSpec = $ini['config']; + } + } catch (\Exception $e) { + // Ignore ini read errors for config section + } } - $theme->setIni($ini['info']); + $theme->setIni($info); $theme->setConfigSpec($configSpec); - // Theme INI must be valid - if (!$manager->iniIsValid($theme)) { - $theme->setState(ThemeManager::STATE_INVALID_INI); - continue; - } - $omekaConstraint = $theme->getIni('omeka_version_constraint'); if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION); From 6a6e0d86de249df1da8122deb3e9188bc09e5dbd Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 04/26] Managed extra key standalone for composer. --- .../src/AddonInstaller.php | 102 ++++++++++++++++- .../src/AddonInstallerPlugin.php | 106 +++++++++++++++++- application/src/Module/InfoReader.php | 1 + 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/application/data/composer-addon-installer/src/AddonInstaller.php b/application/data/composer-addon-installer/src/AddonInstaller.php index 8b3fb3a9d1..779f1d4ebd 100644 --- a/application/data/composer-addon-installer/src/AddonInstaller.php +++ b/application/data/composer-addon-installer/src/AddonInstaller.php @@ -4,17 +4,39 @@ use Composer\Package\PackageInterface; use Composer\Installer\LibraryInstaller; +/** + * Composer installer for Omeka S modules and themes. + * + * Installs packages to addons/modules/ or addons/themes/ based on type. + * Name transformations align with composer/installers OmekaSInstaller. + * + * Supports extra options: + * - installer-name: Explicit install name (overrides auto-detection) + * - standalone: If true, module keeps its own vendor/ directory + */ class AddonInstaller extends LibraryInstaller { /** - * Gets the name this package is to be installed with, either from the - *
extra.install-name
property or the package name. + * Gets the name this package is to be installed with. + * + * Priority: + * 1. extra.installer-name (explicit) + * 2. Auto-transformation based on package type + * + * For modules: removes prefixes/suffixes and converts to CamelCase + * For themes: removes prefixes/suffixes, keeps lowercase with hyphens * * @return string */ public static function getInstallName(PackageInterface $package): string { $extra = $package->getExtra(); + + // Support both installer-name (composer/installers) and install-name + // (legacy). + if (isset($extra['installer-name'])) { + return $extra['installer-name']; + } if (isset($extra['install-name'])) { return $extra['install-name']; } @@ -22,11 +44,79 @@ public static function getInstallName(PackageInterface $package): string $packageName = $package->getPrettyName(); $slashPos = strpos($packageName, '/'); if ($slashPos === false) { - throw new \InvalidArgumentException('Addon package names must contain a slash'); // @translate + throw new \InvalidArgumentException('Add-on package names must contain a slash'); // @translate + } + + $name = substr($packageName, $slashPos + 1); + $type = $package->getType(); + + if ($type === 'omeka-s-module') { + return static::inflectModuleName($name); + } + + if ($type === 'omeka-s-theme') { + return static::inflectThemeName($name); } - $addonName = substr($packageName, $slashPos + 1); - return $addonName; + return $name; + } + + /** + * Transform module name: remove prefixes/suffixes, convert to CamelCase. + * + * Examples: + * - omeka-s-module-common → Common + * - value-suggest → ValueSuggest + * - bulk-import-module → BulkImport + * - neatline-omeka-s → Neatline + * - module-lessonplans → Lessonplans + * + * @param string $name + * @return string + */ + protected static function inflectModuleName($name): string + { + // Remove Omeka prefixes/suffixes. + $name = preg_replace('/^(omeka-?s?-?)?(module-)?/', '', $name); + $name = preg_replace('/(-module)?(-omeka-?s?)?$/', '', $name); + + // Convert kebab-case to CamelCase. + $name = strtr($name, ['-' => ' ']); + $name = strtr(ucwords($name), [' ' => '']); + + return $name; + } + + /** + * Transform theme name: remove prefixes/suffixes, keep lowercase. + * + * Examples: + * - omeka-s-theme-repository → repository + * - my-custom-theme → my-custom + * - flavor-theme-omeka → flavor + * + * @param string $name + * @return string + */ + protected static function inflectThemeName($name): string + { + // Remove Omeka prefixes/suffixes. + $name = preg_replace('/^(omeka-?s?-?)?(theme-)?/', '', $name); + $name = preg_replace('/(-theme)?(-omeka-?s?)?$/', '', $name); + + return $name; + } + + /** + * Check if package wants standalone installation (own vendor/). + * + * @param PackageInterface $package + * @return bool + */ + public static function isStandalone(PackageInterface $package): bool + { + $extra = $package->getExtra(); + return !empty($extra['standalone']); } public function getInstallPath(PackageInterface $package): string @@ -38,7 +128,7 @@ public function getInstallPath(PackageInterface $package): string case 'omeka-s-theme': return 'addons/themes/' . $addonName; default: - throw new \InvalidArgumentException('Invalid Omeka S addon package type'); // @translate + throw new \InvalidArgumentException('Invalid Omeka S add-on package type'); // @translate } } diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index 8a98e7c2be..d4c6ba277b 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -3,13 +3,35 @@ namespace Omeka\Composer; use Composer\Composer; +use Composer\EventDispatcher\EventSubscriberInterface; +use Composer\Installer\PackageEvent; +use Composer\Installer\PackageEvents; use Composer\IO\IOInterface; use Composer\Plugin\PluginInterface; +use Composer\Util\ProcessExecutor; -class AddonInstallerPlugin implements PluginInterface +/** + * Composer plugin for Omeka S add-on installation. + * + * Registers the AddonInstaller and handles standalone modules that need their + * own vendor/ directory. + */ +class AddonInstallerPlugin implements PluginInterface, EventSubscriberInterface { + /** @var Composer */ + protected $composer; + + /** @var IOInterface */ + protected $io; + + /** @var array Packages marked as standalone that need post-install processing */ + protected $standalonePackages = []; + public function activate(Composer $composer, IOInterface $io) { + $this->composer = $composer; + $this->io = $io; + $installer = new AddonInstaller($io, $composer); $composer->getInstallationManager()->addInstaller($installer); } @@ -21,4 +43,86 @@ public function deactivate(Composer $composer, IOInterface $io) public function uninstall(Composer $composer, IOInterface $io) { } + + public static function getSubscribedEvents() + { + return [ + PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', + PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', + ]; + } + + /** + * Handle post-install for standalone packages. + */ + public function onPostPackageInstall(PackageEvent $event) + { + $package = $event->getOperation()->getPackage(); + $this->handleStandalonePackage($package); + } + + /** + * Handle post-update for standalone packages. + */ + public function onPostPackageUpdate(PackageEvent $event) + { + $package = $event->getOperation()->getTargetPackage(); + $this->handleStandalonePackage($package); + } + + /** + * If package is standalone, run composer install in its directory. + * + * Standalone packages have extra.standalone = true in their composer.json. + * This allows them to maintain their own vendor/ directory with specific + * dependency versions, isolated from the root project. + */ + protected function handleStandalonePackage($package) + { + if (!AddonInstaller::isStandalone($package)) { + return; + } + + $type = $package->getType(); + if (!in_array($type, ['omeka-s-module', 'omeka-s-theme'])) { + return; + } + + $installPath = $this->composer->getInstallationManager()->getInstallPath($package); + $composerJson = $installPath . '/composer.json'; + + if (!file_exists($composerJson)) { + $this->io->writeError(sprintf( + 'Standalone package %s has no composer.json, skipping vendor install', + $package->getPrettyName() + )); + return; + } + + $this->io->write(sprintf( + 'Installing standalone dependencies for %s...', + $package->getPrettyName() + )); + + $process = new ProcessExecutor($this->io); + $command = sprintf( + 'cd %s && composer install --no-dev --no-interaction --quiet 2>&1', + escapeshellarg($installPath) + ); + + $exitCode = $process->execute($command, $output); + + if ($exitCode !== 0) { + $this->io->writeError(sprintf( + 'Failed to install standalone dependencies for %s: %s', + $package->getPrettyName(), + $output + )); + } else { + $this->io->write(sprintf( + 'Standalone dependencies installed for %s', + $package->getPrettyName() + )); + } + } } diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index cfd16eb2ed..783d93947c 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -23,6 +23,7 @@ class InfoReader // theme_link is managed below. 'homepage' => 'module_link', 'version' => 'version', + // The mapping of key "standalone" is useless: it does not exist in ini. ]; /** From bc1b6b0879758b55104832aaed56c741737ab825 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 05/26] Added tests for composer addons. --- .../OmekaTest/Composer/AddonInstallerTest.php | 334 ++++++++ .../test/OmekaTest/Module/InfoReaderTest.php | 551 +++++++++++++ .../Service/ModuleManagerFactoryTest.php | 596 ++++++++++++++ .../Service/ThemeManagerFactoryTest.php | 748 ++++++++++++++++++ .../test/OmekaTest/Site/Theme/ThemeTest.php | 76 ++ 5 files changed, 2305 insertions(+) create mode 100644 application/test/OmekaTest/Composer/AddonInstallerTest.php create mode 100644 application/test/OmekaTest/Module/InfoReaderTest.php create mode 100644 application/test/OmekaTest/Service/ModuleManagerFactoryTest.php create mode 100644 application/test/OmekaTest/Service/ThemeManagerFactoryTest.php create mode 100644 application/test/OmekaTest/Site/Theme/ThemeTest.php diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php new file mode 100644 index 0000000000..6763d2edde --- /dev/null +++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php @@ -0,0 +1,334 @@ +markTestSkipped( + 'Composer classes not available. ' . + 'Run "composer require --dev composer/composer" to enable these tests.' + ); + } + } + + /** + * Call protected static method via reflection. + */ + protected function callProtectedMethod(string $method, array $args) + { + $reflection = new \ReflectionClass('Omeka\Composer\AddonInstaller'); + $method = $reflection->getMethod($method); + $method->setAccessible(true); + return $method->invokeArgs(null, $args); + } + + /** + * Create a Composer Package for testing. + */ + protected function createComposerPackage(string $name, string $type, array $extra = []) + { + $class = 'Composer\Package\Package'; + $package = new $class($name, '1.0.0', '1.0.0'); + $package->setType($type); + $package->setExtra($extra); + return $package; + } + + /** + * Get AddonInstaller class (for calling static methods). + */ + protected function getAddonInstallerClass(): string + { + return 'Omeka\Composer\AddonInstaller'; + } + + // ------------------------------------------------------------------------- + // Tests: Module name inflection (protected method) + // ------------------------------------------------------------------------- + + /** + * @dataProvider moduleNameInflectionProvider + */ + public function testInflectModuleName($inputName, $expectedInstallName) + { + $result = $this->callProtectedMethod('inflectModuleName', [$inputName]); + $this->assertEquals($expectedInstallName, $result); + } + + public function moduleNameInflectionProvider() + { + return [ + // Standard prefixes (after vendor/project extraction) + ['omeka-s-module-common', 'Common'], + ['omeka-s-module-advanced-search', 'AdvancedSearch'], + ['omeka-module-value-suggest', 'ValueSuggest'], + + // Without prefix + ['value-suggest', 'ValueSuggest'], + ['bulk-import', 'BulkImport'], + + // With suffix + ['bulk-import-module', 'BulkImport'], + ['neatline-omeka-s', 'Neatline'], + ['module-lessonplans', 'Lessonplans'], + + // Mixed + ['omeka-s-module-easy-admin-module', 'EasyAdmin'], + + // Simple names + ['common', 'Common'], + ['mapping', 'Mapping'], + ['csv-import', 'CsvImport'], + + // Edge cases + ['module', 'Module'], + ['omeka-s', ''], + ]; + } + + // ------------------------------------------------------------------------- + // Tests: Theme name inflection (protected method) + // ------------------------------------------------------------------------- + + /** + * @dataProvider themeNameInflectionProvider + */ + public function testInflectThemeName($inputName, $expectedInstallName) + { + $result = $this->callProtectedMethod('inflectThemeName', [$inputName]); + $this->assertEquals($expectedInstallName, $result); + } + + public function themeNameInflectionProvider() + { + return [ + // Standard prefixes (after vendor/project extraction) + ['omeka-s-theme-repository', 'repository'], + ['omeka-s-theme-foundation-s', 'foundation-s'], + ['omeka-theme-flavor', 'flavor'], + + // Without prefix + ['my-custom', 'my-custom'], + ['cozy', 'cozy'], + + // With suffix + ['my-custom-theme', 'my-custom'], + ['flavor-theme-omeka', 'flavor'], + + // Mixed + ['omeka-s-theme-centerrow-theme-omeka-s', 'centerrow'], + ]; + } + + // ------------------------------------------------------------------------- + // Tests: isStandalone static method + // ------------------------------------------------------------------------- + + public function testIsStandaloneReturnsTrueWhenSet() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => true]); + $this->assertTrue($class::isStandalone($package)); + } + + public function testIsStandaloneReturnsFalseWhenNotSet() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module'); + $this->assertFalse($class::isStandalone($package)); + } + + public function testIsStandaloneReturnsFalseWhenExplicitlyFalse() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => false]); + $this->assertFalse($class::isStandalone($package)); + } + + public function testIsStandaloneReturnsTrueForTruthyValue() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => 1]); + $this->assertTrue($class::isStandalone($package)); + } + + public function testIsStandaloneReturnsFalseForFalsyValue() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => 0]); + $this->assertFalse($class::isStandalone($package)); + } + + public function testIsStandaloneWorksForThemes() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('vendor/my-theme', 'omeka-s-theme', ['standalone' => true]); + $this->assertTrue($class::isStandalone($package)); + } + + // ------------------------------------------------------------------------- + // Tests: getInstallName static method + // ------------------------------------------------------------------------- + + public function testInstallerNameOverridesInflection() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage( + 'vendor/some-weird-name', + 'omeka-s-module', + ['installer-name' => 'CustomModuleName'] + ); + $this->assertEquals('CustomModuleName', $class::getInstallName($package)); + } + + public function testInstallNameLegacyOverridesInflection() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage( + 'vendor/some-weird-name', + 'omeka-s-module', + ['install-name' => 'LegacyName'] + ); + $this->assertEquals('LegacyName', $class::getInstallName($package)); + } + + public function testInstallerNameTakesPrecedenceOverInstallName() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage( + 'vendor/some-weird-name', + 'omeka-s-module', + [ + 'installer-name' => 'PreferredName', + 'install-name' => 'LegacyName', + ] + ); + $this->assertEquals('PreferredName', $class::getInstallName($package)); + } + + public function testGetInstallNameThrowsForPackageWithoutSlash() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must contain a slash'); + + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage('invalid-package-name', 'omeka-s-module'); + $class::getInstallName($package); + } + + // ------------------------------------------------------------------------- + // Tests: Combined scenarios + // ------------------------------------------------------------------------- + + public function testModuleWithStandaloneAndCustomName() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage( + 'vendor/omeka-s-module-test', + 'omeka-s-module', + [ + 'installer-name' => 'MyCustomModule', + 'standalone' => true, + 'label' => 'My Custom Module', + 'omeka-version-constraint' => '^4.0', + ] + ); + + $this->assertEquals('MyCustomModule', $class::getInstallName($package)); + $this->assertTrue($class::isStandalone($package)); + } + + public function testThemeWithStandaloneAndCustomName() + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage( + 'vendor/omeka-s-theme-test', + 'omeka-s-theme', + [ + 'installer-name' => 'my-custom-theme', + 'standalone' => true, + ] + ); + + $this->assertEquals('my-custom-theme', $class::getInstallName($package)); + $this->assertTrue($class::isStandalone($package)); + } + + // ------------------------------------------------------------------------- + // Tests: Real-world package names + // ------------------------------------------------------------------------- + + /** + * @dataProvider realWorldModuleNamesProvider + */ + public function testRealWorldModuleNames($packageName, $expectedInstallName) + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage($packageName, 'omeka-s-module'); + $this->assertEquals($expectedInstallName, $class::getInstallName($package)); + } + + public function realWorldModuleNamesProvider() + { + return [ + // Omeka official modules. + ['omeka-s-modules/Mapping', 'Mapping'], + ['omeka-s-modules/Collecting', 'Collecting'], + ['omeka-s-modules/CustomVocab', 'CustomVocab'], + ['omeka-s-modules/FacetedBrowse', 'FacetedBrowse'], + + // Various naming conventions from different developers. + ['daniel-km/omeka-s-module-common', 'Common'], + ['daniel-km/omeka-s-module-easy-admin', 'EasyAdmin'], + ['zerocrates/HideProperties', 'HideProperties'], + ['chnm/Datascribe-module', 'Datascribe'], + ['manondamoon/omeka-s-module-group', 'Group'], + ]; + } + + /** + * @dataProvider realWorldThemeNamesProvider + */ + public function testRealWorldThemeNames($packageName, $expectedInstallName) + { + $class = $this->getAddonInstallerClass(); + $package = $this->createComposerPackage($packageName, 'omeka-s-theme'); + $this->assertEquals($expectedInstallName, $class::getInstallName($package)); + } + + public function realWorldThemeNamesProvider() + { + return [ + // Omeka official themes. + ['omeka-s-themes/default', 'default'], + ['omeka-s-themes/cozy', 'cozy'], + ['omeka-s-themes/centerrow', 'centerrow'], + ['omeka-s-themes/thedaily', 'thedaily'], + + // Community themes. + ['daniel-km/omeka-s-theme-repository', 'repository'], + ]; + } +} diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php new file mode 100644 index 0000000000..bf88427658 --- /dev/null +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -0,0 +1,551 @@ +testPath = sys_get_temp_dir() . '/omeka_test_info_reader_' . uniqid(); + mkdir($this->testPath, 0755, true); + mkdir($this->testPath . '/config', 0755, true); + + $this->infoReader = new InfoReader(); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->testPath); + parent::tearDown(); + } + + protected function removeDirectory($path) + { + if (!is_dir($path)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + rmdir($path); + } + + // ------------------------------------------------------------------------- + // Tests: module.ini only + // ------------------------------------------------------------------------- + + public function testReadModuleIniOnly() + { + $ini = <<<'INI' + [info] + name = "Test Module" + version = "1.2.3" + author = "John Doe" + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('Test Module', $info['name']); + $this->assertEquals('1.2.3', $info['version']); + $this->assertEquals('John Doe', $info['author']); + } + + public function testReadThemeIniOnly() + { + $ini = <<<'INI' + [info] + name = "Test Theme" + version = "2.0.0" + INI; + file_put_contents($this->testPath . '/config/theme.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertNotNull($info); + $this->assertEquals('Test Theme', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + } + + public function testReadModuleIniWithAllFields() + { + $ini = <<<'INI' + [info] + name = "Complete Module" + version = "3.0.0" + author = "Jane Doe" + description = "A complete test module" + module_link = "https://example.com/module" + author_link = "https://example.com/author" + support_link = "https://example.com/support" + omeka_version_constraint = "^4.0" + configurable = true + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('Complete Module', $info['name']); + $this->assertEquals('3.0.0', $info['version']); + $this->assertEquals('Jane Doe', $info['author']); + $this->assertEquals('A complete test module', $info['description']); + $this->assertEquals('https://example.com/module', $info['module_link']); + $this->assertEquals('https://example.com/author', $info['author_link']); + $this->assertEquals('https://example.com/support', $info['support_link']); + $this->assertEquals('^4.0', $info['omeka_version_constraint']); + $this->assertTrue($info['configurable']); + } + + // ------------------------------------------------------------------------- + // Tests: composer.json only + // ------------------------------------------------------------------------- + + public function testReadComposerJsonOnly() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test-module', + 'description' => 'A test module', + 'version' => '1.0.0', + 'license' => 'GPL-3.0-or-later', + 'homepage' => 'https://example.com', + 'extra' => [ + 'label' => 'Test Module Composer', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('Test Module Composer', $info['name']); + $this->assertEquals('1.0.0', $info['version']); + $this->assertEquals('A test module', $info['description']); + $this->assertEquals('GPL-3.0-or-later', $info['license']); + $this->assertEquals('https://example.com', $info['module_link']); + } + + public function testReadComposerJsonWithExtraAddonVersion() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'version' => '1.0.0', + 'extra' => [ + 'label' => 'Test', + 'addon-version' => '2.5.0', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // addon-version should override composer version. + $this->assertEquals('2.5.0', $info['version']); + } + + public function testReadComposerJsonWithOmekaVersionConstraint() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Test', + 'omeka-version-constraint' => '^4.1', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertEquals('^4.1', $info['omeka_version_constraint']); + } + + public function testReadComposerJsonWithAuthors() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Test', + ], + 'authors' => [ + [ + 'name' => 'John Smith', + 'homepage' => 'https://johnsmith.com', + ], + [ + 'name' => 'Jane Smith', + ], + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Only first author is used. + $this->assertEquals('John Smith', $info['author']); + $this->assertEquals('https://johnsmith.com', $info['author_link']); + } + + public function testReadComposerJsonWithSupport() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Test', + ], + 'support' => [ + 'issues' => 'https://github.com/vendor/test/issues', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertEquals('https://github.com/vendor/test/issues', $info['support_link']); + } + + public function testReadComposerJsonWithKeywords() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Test', + ], + 'keywords' => [ + 'omeka s', + 'module', + 'search', + 'filter', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Generic keywords should be filtered out. + $this->assertEquals('search, filter', $info['tags']); + } + + public function testReadComposerJsonForTheme() + { + $composer = [ + 'name' => 'vendor/omeka-s-theme-test-theme', + 'homepage' => 'https://example.com/theme', + 'extra' => [ + 'label' => 'Test Theme', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertNotNull($info); + $this->assertEquals('Test Theme', $info['name']); + $this->assertEquals('https://example.com/theme', $info['theme_link']); + } + + public function testReadComposerJsonWithConfigurable() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Test', + 'configurable' => true, + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertTrue($info['configurable']); + } + + // ------------------------------------------------------------------------- + // Tests: Both sources (composer.json takes precedence) + // ------------------------------------------------------------------------- + + public function testComposerJsonTakesPrecedenceOverIni() + { + // Create module.ini with some values. + $ini = <<<'INI' + [info] + name = "Module Ini Name" + version = "1.0.0" + description = "Description from ini" + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + // Create composer.json with different values. + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'version' => '2.0.0', + 'description' => 'Description from composer', + 'extra' => [ + 'label' => 'Module Composer Name', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Composer values should take precedence. + $this->assertEquals('Module Composer Name', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + $this->assertEquals('Description from composer', $info['description']); + } + + public function testIniValuesUsedWhenNotInComposer() + { + // Create module.ini with author. + $ini = <<<'INI' + [info] + name = "Module" + version = "1.0.0" + author = "Ini Author" + support_link = "https://ini-support.com" + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + // Create composer.json without author/support. + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'label' => 'Module', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Values not in composer should come from ini. + $this->assertEquals('Ini Author', $info['author']); + $this->assertEquals('https://ini-support.com', $info['support_link']); + } + + // ------------------------------------------------------------------------- + // Tests: Defaults and fallbacks + // ------------------------------------------------------------------------- + + public function testDefaultNameFromDirectoryName() + { + // No label in extra, so should derive from directory. + $composer = [ + 'name' => 'vendor/test', + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Name should be derived from directory or project name. + $this->assertNotEmpty($info['name']); + } + + public function testDefaultVersionWhenMissing() + { + // No version specified. + $ini = <<<'INI' + [info] + name = "Test Module" + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Default version should be applied. + $this->assertEquals('1.0.0', $info['version']); + } + + public function testDefaultConfigurableIsFalse() + { + $ini = <<<'INI' + [info] + name = "Test Module" + version = "1.0.0" + INI; + file_put_contents($this->testPath . '/config/module.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertFalse($info['configurable']); + } + + // ------------------------------------------------------------------------- + // Tests: Invalid or missing sources + // ------------------------------------------------------------------------- + + public function testReadReturnsNullWhenNoSources() + { + // No composer.json and no module.ini. + $info = $this->infoReader->read($this->testPath, 'module'); + + $this->assertNull($info); + } + + public function testIsValidReturnsFalseForNull() + { + $this->assertFalse($this->infoReader->isValid(null)); + } + + public function testIsValidReturnsFalseForEmptyArray() + { + $this->assertFalse($this->infoReader->isValid([])); + } + + public function testIsValidReturnsFalseWithoutName() + { + $this->assertFalse($this->infoReader->isValid(['version' => '1.0.0'])); + } + + public function testIsValidReturnsTrueWithName() + { + $this->assertTrue($this->infoReader->isValid(['name' => 'Test'])); + } + + public function testReadInvalidIniFile() + { + // Create a malformed ini file. + file_put_contents($this->testPath . '/config/module.ini', 'invalid [[ content'); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Should return null when ini is invalid and no composer.json. + $this->assertNull($info); + } + + public function testReadInvalidComposerJson() + { + // Create a malformed JSON file. + file_put_contents($this->testPath . '/composer.json', '{invalid json'); + + $info = $this->infoReader->read($this->testPath, 'module'); + + // Should return null when JSON is invalid and no ini. + $this->assertNull($info); + } + + // ------------------------------------------------------------------------- + // Tests: Project name conversions + // ------------------------------------------------------------------------- + + public function testProjectNameToLabel() + { + $reader = new InfoReader(); + + $this->assertEquals( + 'Easy Admin', + $reader->projectNameToLabel('daniel-km/omeka-s-module-easy-admin') + ); + $this->assertEquals( + 'Advanced Search', + $reader->projectNameToLabel('biblibre/omeka-s-module-advanced-search') + ); + $this->assertEquals( + 'Foundation S', + $reader->projectNameToLabel('omeka-s-themes/foundation-s') + ); + $this->assertEquals( + 'Flavor', + $reader->projectNameToLabel('vendor/omeka-s-theme-flavor') + ); + // Suffixes are removed like AddonInstaller::inflect*. + $this->assertEquals( + 'Datascribe', + $reader->projectNameToLabel('chnm/Datascribe-module') + ); + $this->assertEquals( + 'Neatline', + $reader->projectNameToLabel('vendor/neatline-omeka-s') + ); + } + + public function testProjectNameToDirectory() + { + $reader = new InfoReader(); + + $this->assertEquals( + 'EasyAdmin', + $reader->projectNameToDirectory('daniel-km/omeka-s-module-easy-admin') + ); + $this->assertEquals( + 'AdvancedSearch', + $reader->projectNameToDirectory('biblibre/omeka-s-module-advanced-search') + ); + $this->assertEquals( + 'FoundationS', + $reader->projectNameToDirectory('omeka-s-themes/foundation-s') + ); + // Suffixes are removed like AddonInstaller::inflect*. + $this->assertEquals( + 'Datascribe', + $reader->projectNameToDirectory('chnm/Datascribe-module') + ); + $this->assertEquals( + 'Neatline', + $reader->projectNameToDirectory('vendor/neatline-omeka-s') + ); + } + + // ------------------------------------------------------------------------- + // Tests: Installer name from composer.json + // ------------------------------------------------------------------------- + + public function testGetInstallerNameFromExtra() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-test', + 'extra' => [ + 'installer-name' => 'CustomName', + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $name = $this->infoReader->getInstallerName($this->testPath); + + $this->assertEquals('CustomName', $name); + } + + public function testGetInstallerNameFromProjectName() + { + $composer = [ + 'name' => 'vendor/omeka-s-module-easy-admin', + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $name = $this->infoReader->getInstallerName($this->testPath); + + $this->assertEquals('EasyAdmin', $name); + } + + public function testGetInstallerNameReturnsNullWithoutComposer() + { + $name = $this->infoReader->getInstallerName($this->testPath); + + $this->assertNull($name); + } +} diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php new file mode 100644 index 0000000000..2a8ca81c08 --- /dev/null +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -0,0 +1,596 @@ +testModulesPath = sys_get_temp_dir() . '/omeka_test_modules_' . uniqid(); + $this->testAddonsModulesPath = sys_get_temp_dir() . '/omeka_test_addons_modules_' . uniqid(); + + mkdir($this->testModulesPath, 0755, true); + mkdir($this->testAddonsModulesPath, 0755, true); + } + + protected function tearDown(): void + { + // Clean up test directories. + $this->removeDirectory($this->testModulesPath); + $this->removeDirectory($this->testAddonsModulesPath); + + // Clean up any real modules created in OMEKA_PATH. + foreach ($this->createdModules as $path) { + $this->removeDirectory($path); + } + $this->createdModules = []; + + parent::tearDown(); + } + + protected function removeDirectory($path) + { + if (!is_dir($path)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + rmdir($path); + } + + /** + * Create a test module with module.ini only (traditional manual install). + */ + protected function createModuleWithIni($basePath, $moduleName, $version = '1.0.0', $extraIni = []) + { + $modulePath = $basePath . '/' . $moduleName; + mkdir($modulePath, 0755, true); + mkdir($modulePath . '/config', 0755, true); + + // Create module.ini. + $ini = "[info]\n"; + $ini .= "name = \"$moduleName\"\n"; + $ini .= "version = \"$version\"\n"; + foreach ($extraIni as $key => $value) { + $ini .= "$key = \"$value\"\n"; + } + file_put_contents($modulePath . '/config/module.ini', $ini); + + // Create Module.php. + $php = " 'test/' . strtolower($moduleName), + 'type' => 'omeka-s-module', + 'version' => $version, + 'extra' => array_merge([ + 'label' => $moduleName, + ], $extra), + ]; + file_put_contents($modulePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + // Create Module.php. + $php = " $value) { + $ini .= "$key = \"$value\"\n"; + } + file_put_contents($modulePath . '/config/module.ini', $ini); + + // Create composer.json. + $composer = [ + 'name' => 'test/' . strtolower($moduleName), + 'type' => 'omeka-s-module', + 'version' => $composerVersion, + 'extra' => array_merge([ + 'label' => "$moduleName (composer)", + ], $extraComposer), + ]; + file_put_contents($modulePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + // Create Module.php. + $php = "assertContains(OMEKA_PATH . '/modules', $modulePaths); + $this->assertContains(OMEKA_PATH . '/addons/modules', $modulePaths); + } + + /** + * Test that modules/ comes before addons/modules/ (for priority). + * + * Local modules in modules/ should take precedence over + * composer-installed modules in addons/modules/. + */ + public function testModulesDirectoryHasPriorityOverAddons() + { + $config = include OMEKA_PATH . '/application/config/application.config.php'; + $modulePaths = $config['module_listener_options']['module_paths']; + + $modulesIndex = array_search(OMEKA_PATH . '/modules', $modulePaths); + $addonsIndex = array_search(OMEKA_PATH . '/addons/modules', $modulePaths); + + $this->assertNotFalse($modulesIndex, 'modules/ should be in module_paths'); + $this->assertNotFalse($addonsIndex, 'addons/modules/ should be in module_paths'); + $this->assertLessThan($addonsIndex, $modulesIndex, 'modules/ should come before addons/modules/'); + } + + /** + * Test that addons/modules directory exists. + */ + public function testAddonsModulesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/addons/modules'); + } + + /** + * Test that addons/themes directory exists. + */ + public function testAddonsThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/addons/themes'); + } + + /** + * Test that modules/ directory exists (for local/manual modules). + */ + public function testModulesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/modules'); + } + + /** + * Test that themes/ directory exists (for local/manual themes). + */ + public function testThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/themes'); + } + + // ------------------------------------------------------------------------- + // Tests: InfoReader for modules with different configurations + // ------------------------------------------------------------------------- + + /** + * Test InfoReader reads module with module.ini only. + */ + public function testInfoReaderWithModuleIniOnly() + { + $modulePath = $this->createModuleWithIni( + $this->testModulesPath, + 'TestModuleIni', + '1.0.0', + ['author' => 'Test Author'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('TestModuleIni', $info['name']); + $this->assertEquals('1.0.0', $info['version']); + $this->assertEquals('Test Author', $info['author']); + } + + /** + * Test InfoReader reads module with composer.json only. + */ + public function testInfoReaderWithComposerJsonOnly() + { + $modulePath = $this->createModuleWithComposer( + $this->testModulesPath, + 'TestModuleComposer', + '2.0.0', + ['omeka-version-constraint' => '^4.0'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('TestModuleComposer', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + $this->assertEquals('^4.0', $info['omeka_version_constraint']); + } + + /** + * Test InfoReader merges module.ini and composer.json (composer takes precedence). + */ + public function testInfoReaderWithBothSources() + { + $modulePath = $this->createModuleWithBoth( + $this->testModulesPath, + 'TestModuleBoth', + '1.0.0', // ini version + '2.0.0', // composer version + ['author' => 'Ini Author'], // Only in ini + ['omeka-version-constraint' => '^4.0'] // Only in composer + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertNotNull($info); + // Composer label takes precedence. + $this->assertEquals('TestModuleBoth (composer)', $info['name']); + // Composer version takes precedence. + $this->assertEquals('2.0.0', $info['version']); + // Ini author is preserved (not in composer). + $this->assertEquals('Ini Author', $info['author']); + // Composer constraint is present. + $this->assertEquals('^4.0', $info['omeka_version_constraint']); + } + + /** + * Test InfoReader returns null when no sources exist. + */ + public function testInfoReaderWithNoSources() + { + $modulePath = $this->testModulesPath . '/EmptyModule'; + mkdir($modulePath, 0755, true); + + // Create Module.php only (no config files). + $php = "read($modulePath, 'module'); + + $this->assertNull($info); + } + + /** + * Test InfoReader with addon-version overriding version in composer.json. + */ + public function testInfoReaderWithAddonVersion() + { + $modulePath = $this->testModulesPath . '/AddonVersionModule'; + mkdir($modulePath, 0755, true); + + $composer = [ + 'name' => 'test/addon-version-module', + 'type' => 'omeka-s-module', + 'version' => '1.0.0', + 'extra' => [ + 'label' => 'Addon Version Module', + 'addon-version' => '3.5.0', // Should override version. + ], + ]; + file_put_contents($modulePath . '/composer.json', json_encode($composer)); + file_put_contents($modulePath . '/Module.php', "read($modulePath, 'module'); + + $this->assertEquals('3.5.0', $info['version']); + } + + // ------------------------------------------------------------------------- + // Tests: Priority between modules/ and addons/modules/ + // ------------------------------------------------------------------------- + + /** + * Test that a module in modules/ takes precedence over same module in addons/modules/. + * + * This test creates real modules in OMEKA_PATH to verify factory behavior. + */ + public function testLocalModuleTakesPrecedenceOverAddons() + { + $moduleName = 'TestPriority_' . uniqid(); + $localModulePath = OMEKA_PATH . '/modules/' . $moduleName; + $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; + + $this->createdModules[] = $localModulePath; + $this->createdModules[] = $addonsModulePath; + + try { + // Create module in addons/modules first. + $this->createModuleWithIni($addonsModulePath . '/..', $moduleName, '1.0.0', ['description' => 'Addons version']); + + // Create module in modules/ (should take precedence). + $this->createModuleWithIni($localModulePath . '/..', $moduleName, '2.0.0', ['description' => 'Local version']); + + // Read info using InfoReader - should get local version. + $infoReader = new InfoReader(); + $localInfo = $infoReader->read($localModulePath, 'module'); + $addonsInfo = $infoReader->read($addonsModulePath, 'module'); + + // Both should be readable. + $this->assertNotNull($localInfo); + $this->assertNotNull($addonsInfo); + $this->assertEquals('2.0.0', $localInfo['version']); + $this->assertEquals('1.0.0', $addonsInfo['version']); + $this->assertEquals('Local version', $localInfo['description']); + $this->assertEquals('Addons version', $addonsInfo['description']); + } finally { + // Cleanup is handled in tearDown. + } + } + + /** + * Test module in addons/modules/ only (no local override). + */ + public function testModuleInAddonsOnlyIsRecognized() + { + $moduleName = 'TestAddonsOnly_' . uniqid(); + $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; + + $this->createdModules[] = $addonsModulePath; + + try { + $this->createModuleWithComposer( + OMEKA_PATH . '/addons/modules', + $moduleName, + '1.5.0', + ['omeka-version-constraint' => '^4.0'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($addonsModulePath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals($moduleName, $info['name']); + $this->assertEquals('1.5.0', $info['version']); + } finally { + // Cleanup is handled in tearDown. + } + } + + // ------------------------------------------------------------------------- + // Tests: Module validity checks + // ------------------------------------------------------------------------- + + /** + * Test that a directory without Module.php is detected but invalid. + */ + public function testModuleWithoutModulePhp() + { + $modulePath = $this->testModulesPath . '/NoModulePhp'; + mkdir($modulePath, 0755, true); + mkdir($modulePath . '/config', 0755, true); + + // Create module.ini but no Module.php. + $ini = "[info]\nname = \"No Module PHP\"\nversion = \"1.0.0\"\n"; + file_put_contents($modulePath . '/config/module.ini', $ini); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + // InfoReader should still read the config. + $this->assertNotNull($info); + $this->assertTrue($infoReader->isValid($info)); + + // But Module.php check is done by the factory, not InfoReader. + $this->assertFileDoesNotExist($modulePath . '/Module.php'); + } + + /** + * Test InfoReader with invalid module.ini. + */ + public function testInfoReaderWithInvalidModuleIni() + { + $modulePath = $this->testModulesPath . '/InvalidIni'; + mkdir($modulePath, 0755, true); + mkdir($modulePath . '/config', 0755, true); + + // Create invalid ini file. + file_put_contents($modulePath . '/config/module.ini', 'this is not [[ valid ini'); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + // Should return null (no valid source). + $this->assertNull($info); + } + + /** + * Test InfoReader with invalid composer.json. + */ + public function testInfoReaderWithInvalidComposerJson() + { + $modulePath = $this->testModulesPath . '/InvalidComposer'; + mkdir($modulePath, 0755, true); + + // Create invalid JSON file. + file_put_contents($modulePath . '/composer.json', '{invalid json'); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + // Should return null (no valid source). + $this->assertNull($info); + } + + /** + * Test InfoReader falls back to ini when composer.json is invalid. + */ + public function testInfoReaderFallsBackToIniWhenComposerInvalid() + { + $modulePath = $this->testModulesPath . '/FallbackToIni'; + mkdir($modulePath, 0755, true); + mkdir($modulePath . '/config', 0755, true); + + // Create invalid composer.json. + file_put_contents($modulePath . '/composer.json', '{invalid json'); + + // Create valid module.ini. + $ini = "[info]\nname = \"Fallback Module\"\nversion = \"1.0.0\"\n"; + file_put_contents($modulePath . '/config/module.ini', $ini); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + // Should use ini as fallback. + $this->assertNotNull($info); + $this->assertEquals('Fallback Module', $info['name']); + $this->assertEquals('1.0.0', $info['version']); + } + + // ------------------------------------------------------------------------- + // Tests: Extra composer.json fields + // ------------------------------------------------------------------------- + + /** + * Test configurable flag from composer.json extra. + */ + public function testConfigurableFlagFromComposer() + { + $modulePath = $this->createModuleWithComposer( + $this->testModulesPath, + 'ConfigurableModule', + '1.0.0', + ['configurable' => true] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertTrue($info['configurable']); + } + + /** + * Test configurable defaults to false. + */ + public function testConfigurableDefaultsFalse() + { + $modulePath = $this->createModuleWithComposer( + $this->testModulesPath, + 'NonConfigurableModule', + '1.0.0' + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertFalse($info['configurable']); + } + + /** + * Test installer-name in composer.json. + */ + public function testInstallerNameFromComposer() + { + $modulePath = $this->testModulesPath . '/CustomInstallerName'; + mkdir($modulePath, 0755, true); + + $composer = [ + 'name' => 'vendor/omeka-s-module-some-module', + 'extra' => [ + 'installer-name' => 'CustomInstallerName', + 'label' => 'Custom Module', + ], + ]; + file_put_contents($modulePath . '/composer.json', json_encode($composer)); + + $infoReader = new InfoReader(); + $installerName = $infoReader->getInstallerName($modulePath); + + $this->assertEquals('CustomInstallerName', $installerName); + } + + /** + * Test installer-name derived from project name. + */ + public function testInstallerNameDerivedFromProjectName() + { + $modulePath = $this->testModulesPath . '/DerivedName'; + mkdir($modulePath, 0755, true); + + $composer = [ + 'name' => 'daniel-km/omeka-s-module-easy-admin', + 'extra' => [ + 'label' => 'Easy Admin', + ], + ]; + file_put_contents($modulePath . '/composer.json', json_encode($composer)); + + $infoReader = new InfoReader(); + $installerName = $infoReader->getInstallerName($modulePath); + + $this->assertEquals('EasyAdmin', $installerName); + } +} diff --git a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php new file mode 100644 index 0000000000..3562ce6977 --- /dev/null +++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php @@ -0,0 +1,748 @@ +testThemesPath = sys_get_temp_dir() . '/omeka_test_themes_' . uniqid(); + $this->testAddonsThemesPath = sys_get_temp_dir() . '/omeka_test_addons_themes_' . uniqid(); + + mkdir($this->testThemesPath, 0755, true); + mkdir($this->testAddonsThemesPath, 0755, true); + } + + protected function tearDown(): void + { + // Clean up test directories. + $this->removeDirectory($this->testThemesPath); + $this->removeDirectory($this->testAddonsThemesPath); + + // Clean up any real themes created in OMEKA_PATH. + foreach ($this->createdThemes as $path) { + $this->removeDirectory($path); + } + $this->createdThemes = []; + + parent::tearDown(); + } + + protected function removeDirectory($path) + { + if (!is_dir($path)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + rmdir($path); + } + + /** + * Create a test theme with theme.ini only (traditional manual install). + */ + protected function createThemeWithIni($basePath, $themeName, $version = '1.0.0', $extraIni = []) + { + $themePath = $basePath . '/' . $themeName; + mkdir($themePath, 0755, true); + mkdir($themePath . '/config', 0755, true); + + // Create theme.ini. + $ini = "[info]\n"; + $ini .= "name = \"$themeName\"\n"; + $ini .= "version = \"$version\"\n"; + foreach ($extraIni as $key => $value) { + $ini .= "$key = \"$value\"\n"; + } + file_put_contents($themePath . '/config/theme.ini', $ini); + + return $themePath; + } + + /** + * Create a test theme with composer.json only (composer install without theme.ini). + */ + protected function createThemeWithComposer($basePath, $themeName, $version = '1.0.0', $extra = []) + { + $themePath = $basePath . '/' . $themeName; + mkdir($themePath, 0755, true); + + // Create composer.json. + $composer = [ + 'name' => 'test/' . strtolower($themeName), + 'type' => 'omeka-s-theme', + 'version' => $version, + 'homepage' => 'https://example.com/theme/' . strtolower($themeName), + 'extra' => array_merge([ + 'label' => $themeName, + ], $extra), + ]; + file_put_contents($themePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + return $themePath; + } + + /** + * Create a test theme with both theme.ini and composer.json. + */ + protected function createThemeWithBoth($basePath, $themeName, $iniVersion, $composerVersion, $extraIni = [], $extraComposer = []) + { + $themePath = $basePath . '/' . $themeName; + mkdir($themePath, 0755, true); + mkdir($themePath . '/config', 0755, true); + + // Create theme.ini. + $ini = "[info]\n"; + $ini .= "name = \"$themeName (ini)\"\n"; + $ini .= "version = \"$iniVersion\"\n"; + foreach ($extraIni as $key => $value) { + $ini .= "$key = \"$value\"\n"; + } + file_put_contents($themePath . '/config/theme.ini', $ini); + + // Create composer.json. + $composer = [ + 'name' => 'test/' . strtolower($themeName), + 'type' => 'omeka-s-theme', + 'version' => $composerVersion, + 'homepage' => 'https://example.com/theme/' . strtolower($themeName), + 'extra' => array_merge([ + 'label' => "$themeName (composer)", + ], $extraComposer), + ]; + file_put_contents($themePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + return $themePath; + } + + // Alias for backward compatibility with existing tests. + protected function createTestTheme($basePath, $themeName, $version = '1.0.0') + { + return $this->createThemeWithIni($basePath, $themeName, $version); + } + + /** + * Test that addons/themes directory exists. + */ + public function testAddonsThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/addons/themes'); + } + + /** + * Test that themes/ directory exists (for local/manual themes). + */ + public function testThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/themes'); + } + + /** + * Test that a theme from addons/themes has correct basePath. + */ + public function testThemeFromAddonsDirectoryHasCorrectBasePath() + { + // Create a theme in the real addons directory for testing. + $addonsThemePath = OMEKA_PATH . '/addons/themes/TestThemeAddons_' . uniqid(); + $themeName = basename($addonsThemePath); + + try { + mkdir($addonsThemePath, 0755, true); + mkdir($addonsThemePath . '/config', 0755, true); + + $ini = "[info]\n"; + $ini .= "name = \"Test Theme Addons\"\n"; + $ini .= "version = \"1.0.0\"\n"; + file_put_contents($addonsThemePath . '/config/theme.ini', $ini); + + // Create the factory and invoke it. + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + // Verify theme was registered with correct basePath. + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme); + $this->assertEquals('addons/themes', $theme->getBasePath()); + $this->assertStringContainsString('/addons/themes/' . $themeName, $theme->getPath()); + $this->assertEquals('/addons/themes/' . $themeName . '/theme.jpg', $theme->getThumbnail()); + } finally { + // Clean up. + $this->removeDirectory($addonsThemePath); + } + } + + /** + * Test that a theme from themes/ (standard) has correct basePath. + */ + public function testThemeFromStandardDirectoryHasCorrectBasePath() + { + // Find an existing theme in the standard directory. + $themesDir = OMEKA_PATH . '/themes'; + $foundTheme = null; + + foreach (new \DirectoryIterator($themesDir) as $dir) { + if ($dir->isDir() && !$dir->isDot()) { + $iniFile = $dir->getPathname() . '/config/theme.ini'; + if (file_exists($iniFile)) { + $foundTheme = $dir->getBasename(); + break; + } + } + } + + if (!$foundTheme) { + $this->markTestSkipped('No standard theme found for testing.'); + } + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + $theme = $manager->getTheme($foundTheme); + $this->assertNotNull($theme); + $this->assertEquals('themes', $theme->getBasePath()); + $this->assertStringContainsString('/themes/' . $foundTheme, $theme->getPath()); + $this->assertStringNotContainsString('/addons/themes/', $theme->getPath()); + } + + /** + * Test that local theme (themes/) takes precedence over addons/themes/. + * + * When the same theme exists in both directories, the one in themes/ + * should be loaded (local override of composer-installed theme). + */ + public function testLocalThemeTakesPrecedenceOverAddons() + { + // Create a theme in addons/themes. + $themeName = 'TestPrecedence_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $localThemePath = OMEKA_PATH . '/themes/' . $themeName; + + try { + // Create addons version first. + mkdir($addonsThemePath, 0755, true); + mkdir($addonsThemePath . '/config', 0755, true); + $ini = "[info]\n"; + $ini .= "name = \"$themeName (Addons Version)\"\n"; + $ini .= "version = \"1.0.0\"\n"; + file_put_contents($addonsThemePath . '/config/theme.ini', $ini); + + // Create local version (should take precedence). + mkdir($localThemePath, 0755, true); + mkdir($localThemePath . '/config', 0755, true); + $ini = "[info]\n"; + $ini .= "name = \"$themeName (Local Override)\"\n"; + $ini .= "version = \"2.0.0\"\n"; + file_put_contents($localThemePath . '/config/theme.ini', $ini); + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + // Verify the local version takes precedence. + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme); + $this->assertEquals('themes', $theme->getBasePath()); + $this->assertStringContainsString('Local Override', $theme->getName()); + $this->assertStringContainsString('/themes/' . $themeName, $theme->getPath()); + $this->assertStringNotContainsString('/addons/', $theme->getPath()); + } finally { + // Clean up. + $this->removeDirectory($addonsThemePath); + $this->removeDirectory($localThemePath); + } + } + + // ------------------------------------------------------------------------- + // Tests: InfoReader for themes with different configurations + // ------------------------------------------------------------------------- + + /** + * Test InfoReader reads theme with theme.ini only. + */ + public function testInfoReaderWithThemeIniOnly() + { + $themePath = $this->createThemeWithIni( + $this->testThemesPath, + 'TestThemeIni', + '1.0.0', + ['author' => 'Test Author'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertNotNull($info); + $this->assertEquals('TestThemeIni', $info['name']); + $this->assertEquals('1.0.0', $info['version']); + $this->assertEquals('Test Author', $info['author']); + } + + /** + * Test InfoReader reads theme with composer.json only. + */ + public function testInfoReaderWithComposerJsonOnly() + { + $themePath = $this->createThemeWithComposer( + $this->testThemesPath, + 'TestThemeComposer', + '2.0.0', + ['omeka-version-constraint' => '^4.0'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertNotNull($info); + $this->assertEquals('TestThemeComposer', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + $this->assertEquals('^4.0', $info['omeka_version_constraint']); + // Theme should have theme_link from homepage. + $this->assertEquals('https://example.com/theme/testthemecomposer', $info['theme_link']); + } + + /** + * Test InfoReader merges theme.ini and composer.json (composer takes precedence). + */ + public function testInfoReaderWithBothSources() + { + $themePath = $this->createThemeWithBoth( + $this->testThemesPath, + 'TestThemeBoth', + '1.0.0', // ini version + '2.0.0', // composer version + ['author' => 'Ini Author'], // Only in ini + ['omeka-version-constraint' => '^4.0'] // Only in composer + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertNotNull($info); + // Composer label takes precedence. + $this->assertEquals('TestThemeBoth (composer)', $info['name']); + // Composer version takes precedence. + $this->assertEquals('2.0.0', $info['version']); + // Ini author is preserved (not in composer). + $this->assertEquals('Ini Author', $info['author']); + // Composer constraint is present. + $this->assertEquals('^4.0', $info['omeka_version_constraint']); + } + + /** + * Test InfoReader returns null when no sources exist. + */ + public function testInfoReaderWithNoSources() + { + $themePath = $this->testThemesPath . '/EmptyTheme'; + mkdir($themePath, 0755, true); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertNull($info); + } + + /** + * Test InfoReader with addon-version overriding version in composer.json. + */ + public function testInfoReaderWithAddonVersion() + { + $themePath = $this->testThemesPath . '/AddonVersionTheme'; + mkdir($themePath, 0755, true); + + $composer = [ + 'name' => 'test/addon-version-theme', + 'type' => 'omeka-s-theme', + 'version' => '1.0.0', + 'extra' => [ + 'label' => 'Addon Version Theme', + 'addon-version' => '3.5.0', // Should override version. + ], + ]; + file_put_contents($themePath . '/composer.json', json_encode($composer)); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertEquals('3.5.0', $info['version']); + } + + // ------------------------------------------------------------------------- + // Tests: Theme validity checks + // ------------------------------------------------------------------------- + + /** + * Test InfoReader with invalid theme.ini. + */ + public function testInfoReaderWithInvalidThemeIni() + { + $themePath = $this->testThemesPath . '/InvalidIni'; + mkdir($themePath, 0755, true); + mkdir($themePath . '/config', 0755, true); + + // Create invalid ini file. + file_put_contents($themePath . '/config/theme.ini', 'this is not [[ valid ini'); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + // Should return null (no valid source). + $this->assertNull($info); + } + + /** + * Test InfoReader with invalid composer.json. + */ + public function testInfoReaderWithInvalidComposerJson() + { + $themePath = $this->testThemesPath . '/InvalidComposer'; + mkdir($themePath, 0755, true); + + // Create invalid JSON file. + file_put_contents($themePath . '/composer.json', '{invalid json'); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + // Should return null (no valid source). + $this->assertNull($info); + } + + /** + * Test InfoReader falls back to ini when composer.json is invalid. + */ + public function testInfoReaderFallsBackToIniWhenComposerInvalid() + { + $themePath = $this->testThemesPath . '/FallbackToIni'; + mkdir($themePath, 0755, true); + mkdir($themePath . '/config', 0755, true); + + // Create invalid composer.json. + file_put_contents($themePath . '/composer.json', '{invalid json'); + + // Create valid theme.ini. + $ini = "[info]\nname = \"Fallback Theme\"\nversion = \"1.0.0\"\n"; + file_put_contents($themePath . '/config/theme.ini', $ini); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + // Should use ini as fallback. + $this->assertNotNull($info); + $this->assertEquals('Fallback Theme', $info['name']); + $this->assertEquals('1.0.0', $info['version']); + } + + // ------------------------------------------------------------------------- + // Tests: Theme with composer.json in addons/themes/ + // ------------------------------------------------------------------------- + + /** + * Test theme in addons/themes/ with composer.json only (no theme.ini). + */ + public function testThemeInAddonsWithComposerJsonOnly() + { + $themeName = 'TestAddonsComposer_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + + $this->createdThemes[] = $addonsThemePath; + + try { + mkdir($addonsThemePath, 0755, true); + + $composer = [ + 'name' => 'test/' . strtolower($themeName), + 'type' => 'omeka-s-theme', + 'version' => '1.5.0', + 'description' => 'A composer-only theme', + 'homepage' => 'https://example.com/theme', + 'extra' => [ + 'label' => 'Composer Theme', + 'omeka-version-constraint' => '^4.0', + ], + ]; + file_put_contents($addonsThemePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme, 'Theme with composer.json only should be recognized'); + $this->assertEquals('addons/themes', $theme->getBasePath()); + $this->assertEquals('Composer Theme', $theme->getName()); + $this->assertEquals('1.5.0', $theme->getIni('version')); + $this->assertEquals('A composer-only theme', $theme->getIni('description')); + } finally { + // Cleanup handled by tearDown. + } + } + + /** + * Test theme in addons/themes/ with both theme.ini and composer.json. + */ + public function testThemeInAddonsWithBothSources() + { + $themeName = 'TestAddonsBoth_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + + $this->createdThemes[] = $addonsThemePath; + + try { + mkdir($addonsThemePath, 0755, true); + mkdir($addonsThemePath . '/config', 0755, true); + + // Create theme.ini. + $ini = "[info]\n"; + $ini .= "name = \"$themeName from ini\"\n"; + $ini .= "version = \"1.0.0\"\n"; + $ini .= "author = \"INI Author\"\n"; + file_put_contents($addonsThemePath . '/config/theme.ini', $ini); + + // Create composer.json with different values. + $composer = [ + 'name' => 'test/' . strtolower($themeName), + 'type' => 'omeka-s-theme', + 'version' => '2.0.0', + 'description' => 'Description from composer', + 'extra' => [ + 'label' => "$themeName from composer", + ], + ]; + file_put_contents($addonsThemePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme); + // Composer values should take precedence. + $this->assertEquals("$themeName from composer", $theme->getName()); + $this->assertEquals('2.0.0', $theme->getIni('version')); + $this->assertEquals('Description from composer', $theme->getIni('description')); + // But ini values not in composer should be preserved. + $this->assertEquals('INI Author', $theme->getIni('author')); + } finally { + // Cleanup handled by tearDown. + } + } + + // ------------------------------------------------------------------------- + // Tests: Priority between themes/ and addons/themes/ + // ------------------------------------------------------------------------- + + /** + * Test that a theme in themes/ takes precedence over same theme in addons/themes/. + * + * This test creates real themes in OMEKA_PATH to verify factory behavior. + */ + public function testLocalThemeTakesPrecedenceWithBothUsingComposer() + { + $themeName = 'TestPriorityComposer_' . uniqid(); + $localThemePath = OMEKA_PATH . '/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + + $this->createdThemes[] = $localThemePath; + $this->createdThemes[] = $addonsThemePath; + + try { + // Create theme in addons/themes with composer.json. + mkdir($addonsThemePath, 0755, true); + $addonsComposer = [ + 'name' => 'test/' . strtolower($themeName), + 'version' => '1.0.0', + 'extra' => [ + 'label' => 'Addons Version', + ], + ]; + file_put_contents($addonsThemePath . '/composer.json', json_encode($addonsComposer)); + + // Create theme in themes/ with composer.json (should take precedence). + mkdir($localThemePath, 0755, true); + $localComposer = [ + 'name' => 'test/' . strtolower($themeName), + 'version' => '2.0.0', + 'extra' => [ + 'label' => 'Local Version', + ], + ]; + file_put_contents($localThemePath . '/composer.json', json_encode($localComposer)); + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme); + // Local version should take precedence. + $this->assertEquals('themes', $theme->getBasePath()); + $this->assertEquals('Local Version', $theme->getName()); + $this->assertEquals('2.0.0', $theme->getIni('version')); + } finally { + // Cleanup handled by tearDown. + } + } + + /** + * Test theme in addons/themes/ only (no local override). + */ + public function testThemeInAddonsOnlyIsRecognized() + { + $themeName = 'TestAddonsOnly_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + + $this->createdThemes[] = $addonsThemePath; + + try { + $this->createThemeWithComposer( + OMEKA_PATH . '/addons/themes', + $themeName, + '1.5.0', + ['omeka-version-constraint' => '^4.0'] + ); + + $config = [ + 'page_templates' => [], + 'block_templates' => [], + ]; + $serviceManager = $this->getServiceManager([ + 'Config' => $config, + ]); + + $factory = new ThemeManagerFactory(); + $manager = $factory($serviceManager, 'Omeka\Site\ThemeManager'); + + $theme = $manager->getTheme($themeName); + $this->assertNotNull($theme); + $this->assertEquals('addons/themes', $theme->getBasePath()); + $this->assertEquals($themeName, $theme->getName()); + $this->assertEquals('1.5.0', $theme->getIni('version')); + } finally { + // Cleanup handled by tearDown. + } + } + + // ------------------------------------------------------------------------- + // Tests: installer-name for themes + // ------------------------------------------------------------------------- + + /** + * Test installer-name in composer.json for themes. + */ + public function testInstallerNameFromComposerForTheme() + { + $themePath = $this->testThemesPath . '/CustomInstallerName'; + mkdir($themePath, 0755, true); + + $composer = [ + 'name' => 'vendor/omeka-s-theme-some-theme', + 'extra' => [ + 'installer-name' => 'CustomInstallerName', + 'label' => 'Custom Theme', + ], + ]; + file_put_contents($themePath . '/composer.json', json_encode($composer)); + + $infoReader = new InfoReader(); + $installerName = $infoReader->getInstallerName($themePath); + + $this->assertEquals('CustomInstallerName', $installerName); + } + + /** + * Test installer-name derived from project name for themes. + */ + public function testInstallerNameDerivedFromProjectNameForTheme() + { + $themePath = $this->testThemesPath . '/DerivedName'; + mkdir($themePath, 0755, true); + + $composer = [ + 'name' => 'omeka-s-themes/omeka-s-theme-foundation-s', + 'extra' => [ + 'label' => 'Foundation S', + ], + ]; + file_put_contents($themePath . '/composer.json', json_encode($composer)); + + $infoReader = new InfoReader(); + $installerName = $infoReader->getInstallerName($themePath); + + $this->assertEquals('FoundationS', $installerName); + } +} diff --git a/application/test/OmekaTest/Site/Theme/ThemeTest.php b/application/test/OmekaTest/Site/Theme/ThemeTest.php new file mode 100644 index 0000000000..28c0fbc342 --- /dev/null +++ b/application/test/OmekaTest/Site/Theme/ThemeTest.php @@ -0,0 +1,76 @@ +assertEquals('my-theme', $theme->getId()); + } + + public function testDefaultBasePath() + { + $theme = new Theme('my-theme'); + $this->assertEquals('themes', $theme->getBasePath()); + } + + public function testSetBasePath() + { + $theme = new Theme('my-theme'); + $theme->setBasePath('themes/custom'); + $this->assertEquals('themes/custom', $theme->getBasePath()); + } + + public function testGetPathWithDefaultBasePath() + { + $theme = new Theme('my-theme'); + $path = $theme->getPath(); + $this->assertStringEndsWith('/themes/my-theme', $path); + } + + public function testGetPathWithCustomBasePath() + { + $theme = new Theme('my-theme'); + $theme->setBasePath('themes/custom'); + $path = $theme->getPath(); + $this->assertStringEndsWith('/themes/custom/my-theme', $path); + } + + public function testGetPathWithSubsegments() + { + $theme = new Theme('my-theme'); + $theme->setBasePath('themes/custom'); + $path = $theme->getPath('view', 'layout'); + $this->assertStringEndsWith('/themes/custom/my-theme/view/layout', $path); + } + + public function testGetThumbnailWithDefaultBasePath() + { + $theme = new Theme('my-theme'); + $thumbnail = $theme->getThumbnail(); + $this->assertEquals('/themes/my-theme/theme.jpg', $thumbnail); + } + + public function testGetThumbnailWithCustomBasePath() + { + $theme = new Theme('my-theme'); + $theme->setBasePath('themes/custom'); + $thumbnail = $theme->getThumbnail(); + $this->assertEquals('/themes/custom/my-theme/theme.jpg', $thumbnail); + } + + public function testGetThumbnailWithKeyUsesDefaultPath() + { + // When passing a key for another theme, the default path is used + // because we don't know the basePath of the other theme. + $theme = new Theme('my-theme'); + $theme->setBasePath('themes/custom'); + $thumbnail = $theme->getThumbnail('other-theme'); + $this->assertEquals('/themes/other-theme/theme.jpg', $thumbnail); + } +} From 4bb7e628b6044adcdd8901d5a79a33458221abf8 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 06/26] Added addons/readme.md. --- addons/README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 addons/README.md diff --git a/addons/README.md b/addons/README.md new file mode 100644 index 0000000000..14c03b916c --- /dev/null +++ b/addons/README.md @@ -0,0 +1,49 @@ +Omeka S Addons +============== + +Add-ons are modules and themes managed by composer when they have the type +`omeka-s-module` or `omeka-s-theme`. + +The use of add-ons allows to use a single command `composer require xxx/yyy` +to manage an Omeka instance, to avoid duplication of dependencies, and to +improve speed of Omeka init. With composer, add-ons are installed automatically +under `addons/modules/` and `addons/themes/` and their own dependencies are +shared with the Omeka ones in `vendor/`. + +Omeka S still supports classic locations `modules/` and `themes/`. When a module +or a theme with the same name is located in `modules/` or `themes/`, it is +prioritary and the composer add-on is skipped. + +If a module has no composer.json file or is not available on https://packagist.org, +it still can be managed via composer via the key `repositories` (see +https://getcomposer.org/doc/04-schema.md#repositories). + + +Extra keys +---------- + +The file composer.json supports optional specific keys under key `extra`: + +- `installer-name`: directory to use when different from project name. +- `label`: display label when different from project name. +- `addon-version`: version of add-on for Omeka, else extracted from composer. +- `omeka-version-constraint`: limit compatibility with a specific Omeka version. +- `standalone`: boolean to specify to use own module directory `vendor/`. +- `configurable`: boolean to specify if the module is configurable. + +If an extra key is not available, a check is done for an equivalent in file +`config/module.ini` or `config/theme.ini`, if present, else a default value is +set. + +For assets (libraries for css/img/js/fonts/etc.), no central directory is +defined for now. Each module can manage them as they want, for example in `asset/vendor/`, +with or without composer. It is recommended not to use nodejs to install them to +be consistent with Omeka, that should be manageable on a server without nodejs. + + +Funding +------- + +This feature was funded for the [digital library Manioc](https://manioc.org) of +the [Université des Antilles](https://www.univ-antilles.fr) (subvention +Agence bibliographique de l’enseignement supérieur [Abes](https://abes.fr)). From ffdbec03b276688053e7e41b383fdd8fb325a294 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 07/26] Added specific theme keys in composer. --- addons/README.md | 5 +++++ application/src/Module/InfoReader.php | 2 ++ 2 files changed, 7 insertions(+) diff --git a/addons/README.md b/addons/README.md index 14c03b916c..5237631277 100644 --- a/addons/README.md +++ b/addons/README.md @@ -31,6 +31,11 @@ The file composer.json supports optional specific keys under key `extra`: - `standalone`: boolean to specify to use own module directory `vendor/`. - `configurable`: boolean to specify if the module is configurable. +Specific keys for themes: + +- `has-translations`: boolean to specify if the theme has its own translations. +- `omeka-helpers`: array of custom view helper class names to load. + If an extra key is not available, a check is done for an equivalent in file `config/module.ini` or `config/theme.ini`, if present, else a default value is set. diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 783d93947c..67637e6852 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -36,6 +36,8 @@ class InfoReader 'addon-version' => 'version', 'configurable' => 'configurable', 'omeka-version-constraint' => 'omeka_version_constraint', + 'has-translations' => 'has_translations', + 'omeka-helpers' => 'helpers', ]; /** From 79986fefe13a9765810a2efd5ec06ce4f87b6a96 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 08/26] Loaded add-ons metadata via composer installed.json. --- addons/README.md | 27 +++ application/src/Module/InfoReader.php | 187 +++++++++++++++++- .../src/Service/ModuleManagerFactory.php | 25 ++- .../src/Service/ThemeManagerFactory.php | 22 ++- 4 files changed, 245 insertions(+), 16 deletions(-) diff --git a/addons/README.md b/addons/README.md index 5237631277..a4e5083735 100644 --- a/addons/README.md +++ b/addons/README.md @@ -19,6 +19,29 @@ it still can be managed via composer via the key `repositories` (see https://getcomposer.org/doc/04-schema.md#repositories). +Version compatibility +--------------------- + +Add-ons can require `omeka/omeka-s-core` to declare compatibility with a +specific Omeka version: + +```json +{ + "require": { + "omeka/omeka-s-core": "^4.0" + } +} +``` + +Note: the requirement must not be `omeka/omeka-s`, because `omeka/omeka-s` is +defined as a "project" in the main composer.json. Furthermore, Omeka S uses +composer `provide` mechanism to satisfy this dependency. By this way, the +version constraint is automatically checked at install time. + +For manual add-ons (without composer), use `omeka_version_constraint` in +`config/module.ini` or `config/theme.ini`. + + Extra keys ---------- @@ -40,6 +63,10 @@ If an extra key is not available, a check is done for an equivalent in file `config/module.ini` or `config/theme.ini`, if present, else a default value is set. + +Assets +------ + For assets (libraries for css/img/js/fonts/etc.), no central directory is defined for now. Each module can manage them as they want, for example in `asset/vendor/`, with or without composer. It is recommended not to use nodejs to install them to diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 67637e6852..12940b1454 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -7,10 +7,24 @@ /** * Read module/theme info from composer.json and/or config/*.ini files. * - * Priority: composer.json > module.ini/theme.ini > defaults. + * Priority: installed.json > composer.json > module.ini/theme.ini > defaults. + * + * For performance, installed.json is loaded once and cached. It contains + * metadata for all Composer-installed packages, avoiding individual file reads. + * + * Note: installed.json is used instead of installed.php because installed.php + * only contains minimal data (version, type, install_path) without the `extra` + * keys needed for add-on metadata (omeka-version-constraint, label, etc.). */ class InfoReader { + /** + * Cache of installed.json data, indexed by add-on directory name. + * + * @var array|null + */ + protected static $installedAddons = null; + /** * Mapping from composer.json to ini keys. * @@ -45,7 +59,8 @@ class InfoReader * * @param string $path The module/theme directory path * @param string $type 'module' or 'theme' - * @return array|null Returns merged info array, or null if no valid source found + * @return array|null Returns merged info array, or null if no valid source + * found. */ public function read(string $path, string $type = 'module'): ?array { @@ -340,4 +355,172 @@ public function projectNameToDirectory(string $projectName): string return implode('', $words); } + + /** + * Load installed.json once and cache add-on metadata indexed by dir name. + * + * This avoids reading individual composer.json files for composer-installed + * add-ons, significantly improving bootstrap performance. + */ + public function loadComposerInstalled(): void + { + if (self::$installedAddons !== null) { + return; + } + + self::$installedAddons = ['modules' => [], 'themes' => []]; + + $installedFile = OMEKA_PATH . '/vendor/composer/installed.json'; + if (!is_file($installedFile) || !is_readable($installedFile)) { + return; + } + + $content = file_get_contents($installedFile); + $installed = json_decode($content, true); + if (!$installed) { + return; + } + + // Composer 2.x format: packages are in "packages" key. + $packages = $installed['packages'] ?? $installed; + + foreach ($packages as $package) { + $type = $package['type'] ?? ''; + if ($type === 'omeka-s-module') { + $key = 'modules'; + } elseif ($type === 'omeka-s-theme') { + $key = 'themes'; + } else { + continue; + } + + $extra = $package['extra'] ?? []; + + // Determine the directory name. + $dirName = $extra['installer-name'] + ?? $this->projectNameToDirectory($package['name'] ?? ''); + + if (empty($dirName)) { + continue; + } + + // Build the info array from installed.json data. + $info = $this->buildInfoFromPackage($package); + + self::$installedAddons[$key][$dirName] = $info; + } + } + + /** + * Get add-on info from installed.json cache. + * + * @param string $addonId The add-on directory name + * @param string $type 'module' or 'theme' + * @return array|null The add-on info or null if not found in installed.json. + */ + public function getFromComposerInstalled(string $addonId, string $type = 'module'): ?array + { + $this->loadComposerInstalled(); + + $key = $type === 'module' ? 'modules' : 'themes'; + return self::$installedAddons[$key][$addonId] ?? null; + } + + /** + * Check if an add-on is installed via Composer. + * + * @param string $addonId The add-on directory name + * @param string $type 'module' or 'theme' + */ + public function isComposerInstalled(string $addonId, string $type = 'module'): bool + { + return $this->getFromComposerInstalled($addonId, $type) !== null; + } + + /** + * Build info array from a Composer package entry. + */ + protected function buildInfoFromPackage(array $package): array + { + $extra = $package['extra'] ?? []; + $info = []; + + // Map standard composer.json fields. + foreach ($this->composerToIniMap as $composerKey => $iniKey) { + if (isset($package[$composerKey]) && $package[$composerKey] !== '') { + $info[$iniKey] = $package[$composerKey]; + } + } + + // Map extra fields. + foreach ($this->extraToIniMap as $extraKey => $iniKey) { + if (isset($extra[$extraKey])) { + $info[$iniKey] = $extra[$extraKey]; + } + } + + // Map keywords to tags. + if (isset($package['keywords']) && is_array($package['keywords'])) { + $keywords = array_filter($package['keywords'], function ($keyword) { + return !in_array(strtolower($keyword), [ + 'omeka', + 'omeka s', + 'omeka-s', + 'omeka s module', + 'omeka module', + 'module', + 'omeka s theme', + 'omeka theme', + 'theme', + ]); + }); + if (count($keywords)) { + $info['tags'] = implode(', ', $keywords); + } + } + + // Map authors. + if (isset($package['authors'][0])) { + $firstAuthor = $package['authors'][0]; + if (isset($firstAuthor['name'])) { + $info['author'] = $firstAuthor['name']; + } + if (isset($firstAuthor['homepage'])) { + $info['author_link'] = $firstAuthor['homepage']; + } + } + + // Map support. + if (isset($package['support']['issues'])) { + $info['support_link'] = $package['support']['issues']; + } + + // Specific: theme_link for themes. + if (isset($package['homepage'])) { + $info['theme_link'] = $package['homepage']; + } + + // Apply defaults. + if (empty($info['name'])) { + $info['name'] = $this->projectNameToLabel($package['name'] ?? ''); + } + + // Version: prefer addon-version, then package version. + if (empty($info['version'])) { + $version = $package['version'] ?? '1.0.0'; + $info['version'] = ltrim($version, 'vV'); + } + + $info['configurable'] = !empty($info['configurable']); + + return $info; + } + + /** + * Clear the installed.json cache, mainly for testing. + */ + public static function clearCache(): void + { + self::$installedAddons = null; + } } diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index fe090b838e..7f417e3f50 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -30,8 +30,11 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $infoReader = new InfoReader(); $connection = $serviceLocator->get('Omeka\Connection'); + // Load installed.json once for all Composer-installed modules. + $infoReader->loadComposerInstalled(); + // Get all modules from the filesystem. - // Scan local modules first so they take precedence over addons. + // Scan local modules first so they take precedence over add-ons. $modulePaths = [ OMEKA_PATH . '/modules', OMEKA_PATH . '/addons/modules', @@ -43,7 +46,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar } foreach (new DirectoryIterator($modulePath) as $dir) { - // Module must be a directory + // Module must be a directory. if (!$dir->isDir() || $dir->isDot()) { continue; } @@ -57,10 +60,16 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $module = $manager->registerModule($moduleId); - // Read info from composer.json and/or config/module.ini - $info = $infoReader->read($dir->getPathname(), 'module'); + // Try installed.json first (no file read needed for Composer + // add-ons). + $info = $infoReader->getFromComposerInstalled($moduleId, 'module'); + + // Fallback: read from individual files (manual modules). + if ($info === null) { + $info = $infoReader->read($dir->getPathname(), 'module'); + } - // Module must have valid info (from composer.json or module.ini) + // Module must have valid info if (!$infoReader->isValid($info)) { $module->setState(ModuleManager::STATE_INVALID_INI); continue; @@ -129,7 +138,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar } // Module class must extend Omeka\Module\AbstractModule - // (delay this check until here to avoid loading non-active module files) + // This check is delayed here to avoid loading non-active modules. require_once $module->getModuleFilePath(); $moduleClass = $module->getId() . '\Module'; if (!class_exists($moduleClass) @@ -139,13 +148,13 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar continue; } - // Module valid, installed, and active + // Module valid, installed, and active. $module->setState(ModuleManager::STATE_ACTIVE); } foreach ($manager->getModules() as $id => $module) { if (!$module->getState()) { - // Module in filesystem but not installed + // Module in filesystem but not installed. $module->setState(ModuleManager::STATE_NOT_INSTALLED); } } diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 3efeb084d9..c6ccf84e12 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -23,8 +23,11 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $infoReader = new InfoReader(); $iniReader = new IniReader; + // Load installed.json once for all Composer-installed themes. + $infoReader->loadComposerInstalled(); + // Get all themes from the filesystem. - // Scan local themes first so they take precedence over addons. + // Scan local themes first so they take precedence over add-ons. $themePaths = [ 'themes' => OMEKA_PATH . '/themes', 'addons/themes' => OMEKA_PATH . '/addons/themes', @@ -51,16 +54,23 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $theme = $manager->registerTheme($themeId); $theme->setBasePath($basePath); - // Read info from composer.json and/or config/theme.ini - $info = $infoReader->read($dir->getPathname(), 'theme'); + // Try installed.json first, avoiding reading file for composer. + $info = $infoReader->getFromComposerInstalled($themeId, 'theme'); + + // Fallback: read from individual files (manual themes). + if ($info === null) { + $info = $infoReader->read($dir->getPathname(), 'theme'); + } - // Theme must have valid info (from composer.json or theme.ini) + // Theme must have valid info if (!$infoReader->isValid($info)) { $theme->setState(ThemeManager::STATE_INVALID_INI); continue; } - // Read config spec from theme.ini [config] section if present + // Read config spec from theme.ini [config] section if present. + // This is always needed, even for Composer themes, as [config] + // defines form elements and is not in composer.json. $configSpec = []; $iniFile = $dir->getPathname() . '/config/theme.ini'; if (is_file($iniFile) && is_readable($iniFile)) { @@ -70,7 +80,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $configSpec = $ini['config']; } } catch (\Exception $e) { - // Ignore ini read errors for config section + // Ignore ini read errors for config section. } } From 423b853ae4038a1b94c36772a5532298b228a5fd Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 09/26] Removed check of omeka constraint version for composer add-ons. --- addons/README.md | 2 - application/src/Module/InfoReader.php | 8 ++-- .../src/Service/ModuleManagerFactory.php | 37 +++++++++++++------ .../src/Service/ThemeManagerFactory.php | 34 ++++++++++++----- composer.json | 3 ++ composer.lock | 2 +- 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/addons/README.md b/addons/README.md index a4e5083735..2b1e00971e 100644 --- a/addons/README.md +++ b/addons/README.md @@ -49,8 +49,6 @@ The file composer.json supports optional specific keys under key `extra`: - `installer-name`: directory to use when different from project name. - `label`: display label when different from project name. -- `addon-version`: version of add-on for Omeka, else extracted from composer. -- `omeka-version-constraint`: limit compatibility with a specific Omeka version. - `standalone`: boolean to specify to use own module directory `vendor/`. - `configurable`: boolean to specify if the module is configurable. diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 12940b1454..601df94c7e 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -14,7 +14,7 @@ * * Note: installed.json is used instead of installed.php because installed.php * only contains minimal data (version, type, install_path) without the `extra` - * keys needed for add-on metadata (omeka-version-constraint, label, etc.). + * keys needed for add-on metadata (label, standalone, etc.). */ class InfoReader { @@ -47,9 +47,7 @@ class InfoReader */ protected $extraToIniMap = [ 'label' => 'name', - 'addon-version' => 'version', 'configurable' => 'configurable', - 'omeka-version-constraint' => 'omeka_version_constraint', 'has-translations' => 'has_translations', 'omeka-helpers' => 'helpers', ]; @@ -85,7 +83,7 @@ public function isValid(?array $info): bool } // Required field: name only. - // Version (or extra/addon-version) can be derived from composer. + // Version can be derived from composer or defaults to 1.0.0. if (empty($info['name'])) { return false; } @@ -505,7 +503,7 @@ protected function buildInfoFromPackage(array $package): array $info['name'] = $this->projectNameToLabel($package['name'] ?? ''); } - // Version: prefer addon-version, then package version. + // Version from package, with 'v' prefix removed. if (empty($info['version'])) { $version = $package['version'] ?? '1.0.0'; $info['version'] = ltrim($version, 'vV'); diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index 7f417e3f50..57ea4524e4 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -35,6 +35,11 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar // Get all modules from the filesystem. // Scan local modules first so they take precedence over add-ons. + // Note: addons/modules/ is scanned even though installed.json contains + // the module list. This ensures Module.php exists, handles out-of-sync + // cases, and maintains consistency with how modules/ works. The + // installed.json is only used for metadata (name, version, etc.), not + // for discovering which modules are installed. $modulePaths = [ OMEKA_PATH . '/modules', OMEKA_PATH . '/addons/modules', @@ -60,16 +65,20 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $module = $manager->registerModule($moduleId); - // Try installed.json first (no file read needed for Composer - // add-ons). - $info = $infoReader->getFromComposerInstalled($moduleId, 'module'); - - // Fallback: read from individual files (manual modules). - if ($info === null) { + // Only use installed.json for modules in addons/modules/. + // Local modules in modules/ must read their own files. + $info = null; + $isComposerAddon = strpos($dir->getPathname(), '/addons/modules/') !== false; + if ($isComposerAddon) { + // Try installed.json first to avoid checking compatibility. + $info = $infoReader->getFromComposerInstalled($moduleId, 'module'); + } + if (empty($info)) { + // Fallback: read from individual files (manual modules). $info = $infoReader->read($dir->getPathname(), 'module'); } - // Module must have valid info + // Module must have valid info. if (!$infoReader->isValid($info)) { $module->setState(ModuleManager::STATE_INVALID_INI); continue; @@ -77,7 +86,7 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $module->setIni($info); - // Module directory must contain Module.php + // Module directory must contain Module.php. $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); if (!$moduleFile->isReadable() || !$moduleFile->isFile()) { $module->setState(ModuleManager::STATE_INVALID_MODULE); @@ -85,10 +94,14 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar } $module->setModuleFilePath($moduleFile->getRealPath()); - $omekaConstraint = $module->getIni('omeka_version_constraint'); - if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { - $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION); - continue; + // Check Omeka version constraint only for manual modules. + // Composer add-ons use require.omeka/omeka-s for version checking. + if (!$isComposerAddon) { + $omekaConstraint = $module->getIni('omeka_version_constraint'); + if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { + $module->setState(ModuleManager::STATE_INVALID_OMEKA_VERSION); + continue; + } } } } diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index c6ccf84e12..2face02d6c 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -28,6 +28,11 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar // Get all themes from the filesystem. // Scan local themes first so they take precedence over add-ons. + // Note: addons/themes/ is scanned even though installed.json contains + // the theme list. This ensures theme files exist, handles out-of-sync + // cases, and maintains consistency with how themes/ works. The + // installed.json is only used for metadata (name, version, etc.), not + // for discovering which themes are installed. $themePaths = [ 'themes' => OMEKA_PATH . '/themes', 'addons/themes' => OMEKA_PATH . '/addons/themes', @@ -54,15 +59,20 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $theme = $manager->registerTheme($themeId); $theme->setBasePath($basePath); - // Try installed.json first, avoiding reading file for composer. - $info = $infoReader->getFromComposerInstalled($themeId, 'theme'); - - // Fallback: read from individual files (manual themes). - if ($info === null) { + // Only use installed.json for themes in addons/themes/. + // Local themes in themes/ must read their own files. + $info = null; + $isComposerAddon = strpos($dir->getPathname(), '/addons/themes/') !== false; + if ($isComposerAddon) { + // Try installed.json first to avoid checking compatibility. + $info = $infoReader->getFromComposerInstalled($themeId, 'theme'); + } + if (empty($info)) { + // Fallback: read from individual files (manual themes). $info = $infoReader->read($dir->getPathname(), 'theme'); } - // Theme must have valid info + // Theme must have valid info. if (!$infoReader->isValid($info)) { $theme->setState(ThemeManager::STATE_INVALID_INI); continue; @@ -87,10 +97,14 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $theme->setIni($info); $theme->setConfigSpec($configSpec); - $omekaConstraint = $theme->getIni('omeka_version_constraint'); - if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { - $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION); - continue; + // Check Omeka version constraint only for manual themes. + // Composer add-ons use require.omeka/omeka-s for version checking. + if (!$isComposerAddon) { + $omekaConstraint = $theme->getIni('omeka_version_constraint'); + if ($omekaConstraint !== null && !Semver::satisfies(CoreModule::VERSION, $omekaConstraint)) { + $theme->setState(ThemeManager::STATE_INVALID_OMEKA_VERSION); + continue; + } } $theme->setState(ThemeManager::STATE_ACTIVE); diff --git a/composer.json b/composer.json index 16e0811a69..30419bc499 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,9 @@ "type": "project", "description": "The Omeka S collections management system. A local network of independently curated exhibits sharing a collaboratively built pool of items and their metadata.", "license": "GPL-3.0", + "provide": { + "omeka/omeka-s-core": "self.version" + }, "require": { "php": ">=8.1", "ext-fileinfo": "*", diff --git a/composer.lock b/composer.lock index 9d6193f672..0d80e1654d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "24bc0316b1cf81747ad40ffde6c5da7e", + "content-hash": "837a91aad99679c51b33ad90066fb965", "packages": [ { "name": "beberlei/doctrineextensions", From 3fbe8b88c44e9fe359cf636a602052ef15309f09 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 10/26] Added more tests and fixtures for addons. --- .../OmekaTest/Composer/AddonInstallerTest.php | 1 - .../test/OmekaTest/Module/InfoReaderTest.php | 126 +++++++++++++----- .../addons/modules/TestAddonBasic/Module.php | 16 +++ .../modules/TestAddonBasic/composer.json | 20 +++ .../modules/TestAddonDependency/Module.php | 28 ++++ .../modules/TestAddonDependency/composer.json | 21 +++ .../modules/TestAddonOverride/Module.php | 21 +++ .../modules/TestAddonOverride/composer.json | 20 +++ .../modules/TestAddonStandalone/Module.php | 19 +++ .../modules/TestAddonStandalone/composer.json | 21 +++ .../modules/TestAddonOverride/Module.php | 21 +++ .../TestAddonOverride/config/module.ini | 6 + .../OmekaTest/Service/LoggerFactoryTest.php | 2 +- .../Service/ModuleManagerFactoryTest.php | 117 +++++++++++----- .../Service/ThemeManagerFactoryTest.php | 38 +----- 15 files changed, 371 insertions(+), 106 deletions(-) create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/composer.json create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/Module.php create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/composer.json create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/Module.php create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/composer.json create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/Module.php create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/composer.json create mode 100644 application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php create mode 100644 application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/config/module.ini diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php index 6763d2edde..45e2d74fb4 100644 --- a/application/test/OmekaTest/Composer/AddonInstallerTest.php +++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php @@ -252,7 +252,6 @@ public function testModuleWithStandaloneAndCustomName() 'installer-name' => 'MyCustomModule', 'standalone' => true, 'label' => 'My Custom Module', - 'omeka-version-constraint' => '^4.0', ] ); diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php index bf88427658..35d366f5e3 100644 --- a/application/test/OmekaTest/Module/InfoReaderTest.php +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -152,40 +152,6 @@ public function testReadComposerJsonOnly() $this->assertEquals('https://example.com', $info['module_link']); } - public function testReadComposerJsonWithExtraAddonVersion() - { - $composer = [ - 'name' => 'vendor/omeka-s-module-test', - 'version' => '1.0.0', - 'extra' => [ - 'label' => 'Test', - 'addon-version' => '2.5.0', - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'module'); - - // addon-version should override composer version. - $this->assertEquals('2.5.0', $info['version']); - } - - public function testReadComposerJsonWithOmekaVersionConstraint() - { - $composer = [ - 'name' => 'vendor/omeka-s-module-test', - 'extra' => [ - 'label' => 'Test', - 'omeka-version-constraint' => '^4.1', - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'module'); - - $this->assertEquals('^4.1', $info['omeka_version_constraint']); - } - public function testReadComposerJsonWithAuthors() { $composer = [ @@ -286,6 +252,98 @@ public function testReadComposerJsonWithConfigurable() $this->assertTrue($info['configurable']); } + public function testReadComposerJsonWithHasTranslations() + { + $composer = [ + 'name' => 'vendor/omeka-s-theme-test', + 'extra' => [ + 'label' => 'Test Theme', + 'has-translations' => true, + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertTrue($info['has_translations']); + } + + public function testReadComposerJsonWithOmekaHelpers() + { + $composer = [ + 'name' => 'vendor/omeka-s-theme-test', + 'extra' => [ + 'label' => 'Test Theme', + 'omeka-helpers' => ['ThemeFunctions', 'Breadcrumbs'], + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertIsArray($info['helpers']); + $this->assertEquals(['ThemeFunctions', 'Breadcrumbs'], $info['helpers']); + } + + public function testReadThemeIniWithHasTranslations() + { + $ini = <<<'INI' + [info] + name = "Test Theme" + version = "1.0.0" + has_translations = true + INI; + file_put_contents($this->testPath . '/config/theme.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertTrue((bool) $info['has_translations']); + } + + public function testReadThemeIniWithHelpers() + { + $ini = <<<'INI' + [info] + name = "Test Theme" + version = "1.0.0" + helpers[] = ThemeFunctions + helpers[] = Breadcrumbs + INI; + file_put_contents($this->testPath . '/config/theme.ini', $ini); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + $this->assertIsArray($info['helpers']); + $this->assertEquals(['ThemeFunctions', 'Breadcrumbs'], $info['helpers']); + } + + public function testComposerOmekaHelpersOverrideIniHelpers() + { + // Create theme.ini with helpers. + $ini = <<<'INI' + [info] + name = "Test Theme" + version = "1.0.0" + helpers[] = IniHelper + INI; + file_put_contents($this->testPath . '/config/theme.ini', $ini); + + // Create composer.json with different helpers. + $composer = [ + 'name' => 'vendor/omeka-s-theme-test', + 'extra' => [ + 'label' => 'Test Theme', + 'omeka-helpers' => ['ComposerHelper'], + ], + ]; + file_put_contents($this->testPath . '/composer.json', json_encode($composer)); + + $info = $this->infoReader->read($this->testPath, 'theme'); + + // Composer omeka-helpers should take precedence. + $this->assertEquals(['ComposerHelper'], $info['helpers']); + } + // ------------------------------------------------------------------------- // Tests: Both sources (composer.json takes precedence) // ------------------------------------------------------------------------- diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php b/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php new file mode 100644 index 0000000000..78ae7a6c9c --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php @@ -0,0 +1,16 @@ +" +description = "Local module that should take precedence over the composer version in addons/modules/" +omeka_version_constraint = "^4.0" diff --git a/application/test/OmekaTest/Service/LoggerFactoryTest.php b/application/test/OmekaTest/Service/LoggerFactoryTest.php index fb22710c1f..dee7018bdb 100644 --- a/application/test/OmekaTest/Service/LoggerFactoryTest.php +++ b/application/test/OmekaTest/Service/LoggerFactoryTest.php @@ -12,7 +12,7 @@ class LoggerFactoryTest extends TestCase protected $validConfig = [ 'logger' => [ 'log' => true, - 'path' => '/', + 'path' => '/tmp/omeka-test.log', 'priority' => Logger::NOTICE, ], ]; diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php index 2a8ca81c08..291c8b5f90 100644 --- a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -264,8 +264,7 @@ public function testInfoReaderWithComposerJsonOnly() $modulePath = $this->createModuleWithComposer( $this->testModulesPath, 'TestModuleComposer', - '2.0.0', - ['omeka-version-constraint' => '^4.0'] + '2.0.0' ); $infoReader = new InfoReader(); @@ -274,7 +273,6 @@ public function testInfoReaderWithComposerJsonOnly() $this->assertNotNull($info); $this->assertEquals('TestModuleComposer', $info['name']); $this->assertEquals('2.0.0', $info['version']); - $this->assertEquals('^4.0', $info['omeka_version_constraint']); } /** @@ -287,8 +285,7 @@ public function testInfoReaderWithBothSources() 'TestModuleBoth', '1.0.0', // ini version '2.0.0', // composer version - ['author' => 'Ini Author'], // Only in ini - ['omeka-version-constraint' => '^4.0'] // Only in composer + ['author' => 'Ini Author'] // Only in ini ); $infoReader = new InfoReader(); @@ -301,8 +298,6 @@ public function testInfoReaderWithBothSources() $this->assertEquals('2.0.0', $info['version']); // Ini author is preserved (not in composer). $this->assertEquals('Ini Author', $info['author']); - // Composer constraint is present. - $this->assertEquals('^4.0', $info['omeka_version_constraint']); } /** @@ -323,32 +318,6 @@ public function testInfoReaderWithNoSources() $this->assertNull($info); } - /** - * Test InfoReader with addon-version overriding version in composer.json. - */ - public function testInfoReaderWithAddonVersion() - { - $modulePath = $this->testModulesPath . '/AddonVersionModule'; - mkdir($modulePath, 0755, true); - - $composer = [ - 'name' => 'test/addon-version-module', - 'type' => 'omeka-s-module', - 'version' => '1.0.0', - 'extra' => [ - 'label' => 'Addon Version Module', - 'addon-version' => '3.5.0', // Should override version. - ], - ]; - file_put_contents($modulePath . '/composer.json', json_encode($composer)); - file_put_contents($modulePath . '/Module.php', "read($modulePath, 'module'); - - $this->assertEquals('3.5.0', $info['version']); - } - // ------------------------------------------------------------------------- // Tests: Priority between modules/ and addons/modules/ // ------------------------------------------------------------------------- @@ -405,8 +374,7 @@ public function testModuleInAddonsOnlyIsRecognized() $this->createModuleWithComposer( OMEKA_PATH . '/addons/modules', $moduleName, - '1.5.0', - ['omeka-version-constraint' => '^4.0'] + '1.5.0' ); $infoReader = new InfoReader(); @@ -593,4 +561,83 @@ public function testInstallerNameDerivedFromProjectName() $this->assertEquals('EasyAdmin', $installerName); } + + // ------------------------------------------------------------------------- + // Tests: Local module info takes precedence over installed.json + // ------------------------------------------------------------------------- + + /** + * Test that local module info is used even when module exists in installed.json. + * + * This is a critical test for the override feature: when a module exists in + * both modules/ (local) and addons/modules/ (composer), AND there's an entry + * in installed.json, the LOCAL module's info (from module.ini or composer.json) + * must be used, NOT the installed.json entry. + * + * This allows administrators to override a composer-installed module with a + * customized local version that has different metadata (version, description, etc.). + */ + public function testLocalModuleInfoTakesPrecedenceOverInstalledJson() + { + // Use the test fixtures which have an entry in installed.json. + $moduleName = 'TestAddonOverride'; + $localModulePath = OMEKA_PATH . '/modules/' . $moduleName; + $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; + + // Skip if test fixtures aren't set up. + if (!is_dir($localModulePath) || !is_dir($addonsModulePath)) { + $this->markTestSkipped( + 'Test fixtures not available. Run the test setup to create ' . + 'TestAddonOverride in both modules/ and addons/modules/.' + ); + } + + // Clear the InfoReader cache to ensure fresh reads. + InfoReader::clearCache(); + + $infoReader = new InfoReader(); + + // The installed.json has "Test Addon Override (Composer version)". + $installedInfo = $infoReader->getFromComposerInstalled($moduleName, 'module'); + $this->assertNotNull($installedInfo, 'Module should exist in installed.json'); + $this->assertStringContainsString('Composer', $installedInfo['name']); + + // The local module.ini has "Test Addon Override (Local version)". + $localInfo = $infoReader->read($localModulePath, 'module'); + $this->assertNotNull($localInfo, 'Local module should be readable'); + $this->assertStringContainsString('Local', $localInfo['name']); + + // Test the path-based decision logic used by ModuleManagerFactory. + // For a module in modules/, strpos should NOT find '/addons/modules/'. + $this->assertFalse( + strpos($localModulePath, '/addons/modules/') !== false, + 'Local module path should not contain /addons/modules/' + ); + + // For a module in addons/modules/, strpos SHOULD find '/addons/modules/'. + $this->assertTrue( + strpos($addonsModulePath, '/addons/modules/') !== false, + 'Addons module path should contain /addons/modules/' + ); + + // Simulate the factory logic: for local modules, DON'T use installed.json. + $isComposerAddon = strpos($localModulePath, '/addons/modules/') !== false; + $this->assertFalse($isComposerAddon, 'Local module should not be treated as composer addon'); + + // The correct info should come from read(), not getFromComposerInstalled(). + $info = null; + if ($isComposerAddon) { + $info = $infoReader->getFromComposerInstalled($moduleName, 'module'); + } + if (empty($info)) { + $info = $infoReader->read($localModulePath, 'module'); + } + + $this->assertStringContainsString( + 'Local', + $info['name'], + 'Module info should come from local module.ini, not installed.json. ' . + 'Got: ' . $info['name'] + ); + } } diff --git a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php index 3562ce6977..d202b49031 100644 --- a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php @@ -338,8 +338,7 @@ public function testInfoReaderWithComposerJsonOnly() $themePath = $this->createThemeWithComposer( $this->testThemesPath, 'TestThemeComposer', - '2.0.0', - ['omeka-version-constraint' => '^4.0'] + '2.0.0' ); $infoReader = new InfoReader(); @@ -348,7 +347,6 @@ public function testInfoReaderWithComposerJsonOnly() $this->assertNotNull($info); $this->assertEquals('TestThemeComposer', $info['name']); $this->assertEquals('2.0.0', $info['version']); - $this->assertEquals('^4.0', $info['omeka_version_constraint']); // Theme should have theme_link from homepage. $this->assertEquals('https://example.com/theme/testthemecomposer', $info['theme_link']); } @@ -363,8 +361,7 @@ public function testInfoReaderWithBothSources() 'TestThemeBoth', '1.0.0', // ini version '2.0.0', // composer version - ['author' => 'Ini Author'], // Only in ini - ['omeka-version-constraint' => '^4.0'] // Only in composer + ['author' => 'Ini Author'] // Only in ini ); $infoReader = new InfoReader(); @@ -377,8 +374,6 @@ public function testInfoReaderWithBothSources() $this->assertEquals('2.0.0', $info['version']); // Ini author is preserved (not in composer). $this->assertEquals('Ini Author', $info['author']); - // Composer constraint is present. - $this->assertEquals('^4.0', $info['omeka_version_constraint']); } /** @@ -395,31 +390,6 @@ public function testInfoReaderWithNoSources() $this->assertNull($info); } - /** - * Test InfoReader with addon-version overriding version in composer.json. - */ - public function testInfoReaderWithAddonVersion() - { - $themePath = $this->testThemesPath . '/AddonVersionTheme'; - mkdir($themePath, 0755, true); - - $composer = [ - 'name' => 'test/addon-version-theme', - 'type' => 'omeka-s-theme', - 'version' => '1.0.0', - 'extra' => [ - 'label' => 'Addon Version Theme', - 'addon-version' => '3.5.0', // Should override version. - ], - ]; - file_put_contents($themePath . '/composer.json', json_encode($composer)); - - $infoReader = new InfoReader(); - $info = $infoReader->read($themePath, 'theme'); - - $this->assertEquals('3.5.0', $info['version']); - } - // ------------------------------------------------------------------------- // Tests: Theme validity checks // ------------------------------------------------------------------------- @@ -511,7 +481,6 @@ public function testThemeInAddonsWithComposerJsonOnly() 'homepage' => 'https://example.com/theme', 'extra' => [ 'label' => 'Composer Theme', - 'omeka-version-constraint' => '^4.0', ], ]; file_put_contents($addonsThemePath . '/composer.json', json_encode($composer, JSON_PRETTY_PRINT)); @@ -672,8 +641,7 @@ public function testThemeInAddonsOnlyIsRecognized() $this->createThemeWithComposer( OMEKA_PATH . '/addons/themes', $themeName, - '1.5.0', - ['omeka-version-constraint' => '^4.0'] + '1.5.0' ); $config = [ From 14ab8666aa0167af1bad36cd3b939f9b5863ce11 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 11/26] Cleaned description by removing "Module/Theme for Omeka S:". --- application/src/Module/InfoReader.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 601df94c7e..e0934cb342 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -511,6 +511,15 @@ protected function buildInfoFromPackage(array $package): array $info['configurable'] = !empty($info['configurable']); + // Clean up description: remove common prefixes like "Module for Omeka S:". + if (!empty($info['description'])) { + $info['description'] = preg_replace( + '/^(Module|Theme)\s+for\s+Omeka\s*S?\s*:\s*/i', + '', + $info['description'] + ); + } + return $info; } From 6ca56622391ae4b32eb628fe9d1cac2afc3a672b Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 12/26] Added theme test-override for testing theme overriding. --- .../addons/themes/test-override/composer.json | 24 +++++++++++++ .../themes/test-override/config/theme.ini | 6 ++++ .../test-override/view/layout/layout.phtml | 34 +++++++++++++++++++ .../themes/test-override/config/theme.ini | 6 ++++ .../test-override/view/layout/layout.phtml | 34 +++++++++++++++++++ 5 files changed, 104 insertions(+) create mode 100644 application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json create mode 100644 application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini create mode 100644 application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml create mode 100644 application/test/OmekaTest/Module/fixtures/themes/test-override/config/theme.ini create mode 100644 application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json new file mode 100644 index 0000000000..59ec7f6fca --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json @@ -0,0 +1,24 @@ +{ + "name": "test/omeka-s-theme-test-override", + "type": "omeka-s-theme", + "description": "Theme for Omeka S: Test theme to verify themes/ takes precedence over addons/themes/", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Test Author", + "email": "test@example.org" + } + ], + "keywords": [ + "Omeka S", + "theme", + "test" + ], + "require": { + "omeka/omeka-s-core": "^4.2" + }, + "extra": { + "installer-name": "test-override", + "label": "Test Override (composer version)" + } +} diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini new file mode 100644 index 0000000000..2c2d7aab8d --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini @@ -0,0 +1,6 @@ +[info] +name = "Test Override (composer version)" +version = "1.0.0" +author = "Test Author " +description = "Test theme (addons/themes/) to verify themes/ takes precedence" +omeka_version_constraint = "^4.2" diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml new file mode 100644 index 0000000000..06c1208eeb --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml @@ -0,0 +1,34 @@ +getHelperPluginManager(); +$testAddonDirectory = $plugins->has('testAddonDirectory') ? $plugins->get('testAddonDirectory') : null; + +$this->headLink()->prependStylesheet($this->assetUrl('css/style.css', 'Omeka')); +?> + + + + + + headMeta() ?> + headLink() ?> + headStyle() ?> + headScript() ?> + <?= $this->pageTitle() ?> + + +
+ Theme: TestOverride (composer version)
+ Theme path:
+ Module path: +
+
+ content ?> +
+ + diff --git a/application/test/OmekaTest/Module/fixtures/themes/test-override/config/theme.ini b/application/test/OmekaTest/Module/fixtures/themes/test-override/config/theme.ini new file mode 100644 index 0000000000..321103af5c --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/themes/test-override/config/theme.ini @@ -0,0 +1,6 @@ +[info] +name = "Test Override (local version)" +version = "1.0.0-local" +author = "Test Author " +description = "Local theme (themes/) that should take precedence over addons/themes/" +omeka_version_constraint = "^4.2" diff --git a/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml new file mode 100644 index 0000000000..fff739696c --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/themes/test-override/view/layout/layout.phtml @@ -0,0 +1,34 @@ +getHelperPluginManager(); +$testAddonDirectory = $plugins->has('testAddonDirectory') ? $plugins->get('testAddonDirectory') : null; + +$this->headLink()->prependStylesheet($this->assetUrl('css/style.css', 'Omeka')); +?> + + + + + + headMeta() ?> + headLink() ?> + headStyle() ?> + headScript() ?> + <?= $this->pageTitle() ?> + + +
+ Theme: Test Override (local version)
+ Theme path:
+ Module path: +
+
+ content ?> +
+ + From 8e29fb5513edd5570ad69ee8247b78de6fe7b1ae Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 13/26] Added script and key omeka-assets to simplify assets management. --- addons/README.md | 55 ++- .../src/AddonInstallerPlugin.php | 3 - application/data/omeka-assets/composer.json | 22 ++ .../omeka-assets/src/OmekaAssetsPlugin.php | 245 +++++++++++++ .../data/scripts/install-omeka-assets.php | 226 ++++++++++++ .../src/Module/OmekaAssetsInstaller.php | 343 ++++++++++++++++++ composer.json | 8 +- composer.lock | 34 +- 8 files changed, 927 insertions(+), 9 deletions(-) create mode 100644 application/data/omeka-assets/composer.json create mode 100644 application/data/omeka-assets/src/OmekaAssetsPlugin.php create mode 100644 application/data/scripts/install-omeka-assets.php create mode 100644 application/src/Module/OmekaAssetsInstaller.php diff --git a/addons/README.md b/addons/README.md index 2b1e00971e..8a098ca347 100644 --- a/addons/README.md +++ b/addons/README.md @@ -51,6 +51,7 @@ The file composer.json supports optional specific keys under key `extra`: - `label`: display label when different from project name. - `standalone`: boolean to specify to use own module directory `vendor/`. - `configurable`: boolean to specify if the module is configurable. +- `omeka-assets`: list external assets to download (see section Assets below). Specific keys for themes: @@ -65,10 +66,56 @@ set. Assets ------ -For assets (libraries for css/img/js/fonts/etc.), no central directory is -defined for now. Each module can manage them as they want, for example in `asset/vendor/`, -with or without composer. It is recommended not to use nodejs to install them to -be consistent with Omeka, that should be manageable on a server without nodejs. +For assets (libraries for css/img/js/fonts/etc.), modules and themes can define +external files to download automatically during composer installation using the +`omeka-assets` key under `extra`: + +```json +{ + "extra": { + "omeka-assets": { + "asset/vendor/lib/custom.min.js": "https://example.com/v3.4.0/file.min.js", + "asset/vendor/lib/": "https://example.com/v3.4.1/archive.zip", + "asset/vendor/scripts/": "https://example.com/script.js" + } + } +} +``` + +The key is the destination path relative to the add-on directory, the value is +the url to download. + +- If destination ends with a filename, download url and rename to that name. +- If destination ends with `/` and url has `.zip`/`.tar.gz`/`.tgz`, extract it. + When the archive contains a single root directory, it is stripped. +- If destination ends with `/` and url is a file, copy it into that directory. + +This mechanism avoids the need for custom `repositories` in composer.json, +which are not inherited from composer dependencies. + +It is recommended not to use nodejs to install assets to be consistent with +Omeka, that should be manageable on a server without nodejs. + +The assets feature is provided by the internal separate composer plugin `omeka/omeka-assets`. + +### Manual installation (git clone) + +For modules installed via git clone, assets are not downloaded automatically. +Use the script to install them (adapt path according to current directory): + +```bash +# Install assets for a specific module. +php application/data/scripts/install-omeka-assets.php ModuleName + +# Install assets for a theme. +php application/data/scripts/install-omeka-assets.php --theme theme-name + +# Install assets for all modules and themes. +php application/data/scripts/install-omeka-assets.php --all + +# Force re-download (even if assets already exist). +php application/data/scripts/install-omeka-assets.php --force ModuleName +``` Funding diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index d4c6ba277b..d97844d648 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -24,9 +24,6 @@ class AddonInstallerPlugin implements PluginInterface, EventSubscriberInterface /** @var IOInterface */ protected $io; - /** @var array Packages marked as standalone that need post-install processing */ - protected $standalonePackages = []; - public function activate(Composer $composer, IOInterface $io) { $this->composer = $composer; diff --git a/application/data/omeka-assets/composer.json b/application/data/omeka-assets/composer.json new file mode 100644 index 0000000000..9d44a79209 --- /dev/null +++ b/application/data/omeka-assets/composer.json @@ -0,0 +1,22 @@ +{ + "name": "omeka/omeka-assets", + "type": "composer-plugin", + "version": "1.0.0", + "description": "Composer plugin to download external assets for Omeka S modules and themes", + "license": "GPL-3.0-or-later", + "require": { + "php": ">=8.1", + "composer-plugin-api": "^2.0" + }, + "require-dev": { + "composer/composer": "^2.0" + }, + "autoload": { + "psr-4": { + "Omeka\\OmekaAssets\\": "src/" + } + }, + "extra": { + "class": "Omeka\\OmekaAssets\\OmekaAssetsPlugin" + } +} diff --git a/application/data/omeka-assets/src/OmekaAssetsPlugin.php b/application/data/omeka-assets/src/OmekaAssetsPlugin.php new file mode 100644 index 0000000000..9a8abe120c --- /dev/null +++ b/application/data/omeka-assets/src/OmekaAssetsPlugin.php @@ -0,0 +1,245 @@ +composer = $composer; + $this->io = $io; + } + + public function deactivate(Composer $composer, IOInterface $io) + { + } + + public function uninstall(Composer $composer, IOInterface $io) + { + } + + public static function getSubscribedEvents() + { + return [ + PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', + PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', + ]; + } + + /** + * Handle post-install for packages with omeka-assets. + */ + public function onPostPackageInstall(PackageEvent $event) + { + $package = $event->getOperation()->getPackage(); + $this->handleOmekaAssets($package); + } + + /** + * Handle post-update for packages with omeka-assets. + */ + public function onPostPackageUpdate(PackageEvent $event) + { + $package = $event->getOperation()->getTargetPackage(); + $this->handleOmekaAssets($package); + } + + /** + * Download and install assets defined in extra.omeka-assets. + */ + protected function handleOmekaAssets($package) + { + $extra = $package->getExtra(); + if (empty($extra['omeka-assets']) || !is_array($extra['omeka-assets'])) { + return; + } + + $installPath = $this->composer->getInstallationManager()->getInstallPath($package); + + foreach ($extra['omeka-assets'] as $destination => $url) { + $destPath = $installPath . '/' . ltrim($destination, '/'); + $isDirectory = substr($destination, -1) === '/'; + $isArchive = preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); + + $this->io->write(sprintf( + 'Downloading asset %s for %s...', + basename($url), + $package->getPrettyName() + )); + + try { + if ($isDirectory && $isArchive) { + $this->downloadAndExtract($url, $destPath); + } elseif ($isDirectory) { + // Directory destination + non-archive URL: copy file into directory. + $this->downloadFile($url, $destPath . basename($url)); + } else { + $this->downloadFile($url, $destPath); + } + } catch (\Exception $e) { + $this->io->writeError(sprintf( + 'Failed to download asset %s: %s', + $url, + $e->getMessage() + )); + } + } + } + + /** + * Download a single file using composer HttpDownloader. + */ + protected function downloadFile(string $url, string $destPath): void + { + $filesystem = new Filesystem(); + $filesystem->ensureDirectoryExists(dirname($destPath)); + + $httpDownloader = new HttpDownloader($this->io, $this->composer->getConfig()); + $httpDownloader->copy($url, $destPath); + } + + /** + * Download and extract an archive using composer utilities. + * + * If the archive contains a single root directory, its contents are + * extracted directly to the destination (stripping the root directory). + */ + protected function downloadAndExtract(string $url, string $destPath): void + { + $filesystem = new Filesystem(); + + $tempFile = sys_get_temp_dir() . '/' . basename($url); + $tempDir = sys_get_temp_dir() . '/omeka_extract_' . uniqid(); + + $filesystem->ensureDirectoryExists($tempDir); + + $httpDownloader = new HttpDownloader($this->io, $this->composer->getConfig()); + $httpDownloader->copy($url, $tempFile); + + // Use composer archive extractor via process. + $process = new ProcessExecutor($this->io); + + if (preg_match('/\.zip$/i', $url)) { + // Try unzip command first, fallback to php ZipArchive. + $command = sprintf('unzip -o -q %s -d %s 2>&1', escapeshellarg($tempFile), escapeshellarg($tempDir)); + if ($process->execute($command) !== 0) { + // Fallback to ZipArchive if unzip is not available. + if (!class_exists('ZipArchive')) { + $filesystem->unlink($tempFile); + $filesystem->removeDirectory($tempDir); + throw new \RuntimeException('Cannot extract zip: unzip command failed and ZipArchive not available'); + } + $zip = new \ZipArchive(); + if ($zip->open($tempFile) !== true) { + $filesystem->unlink($tempFile); + $filesystem->removeDirectory($tempDir); + throw new \RuntimeException('Failed to open zip archive'); + } + $zip->extractTo($tempDir); + $zip->close(); + } + } elseif (preg_match('/\.(tar\.gz|tgz)$/i', $url)) { + $command = sprintf('tar -xzf %s -C %s 2>&1', escapeshellarg($tempFile), escapeshellarg($tempDir)); + if ($process->execute($command) !== 0) { + // Fallback to PharData. + $phar = new \PharData($tempFile); + $phar->extractTo($tempDir); + } + } + + $filesystem->unlink($tempFile); + + // Check if archive has a single root directory and strip it. + $sourceDir = $this->getArchiveSourceDir($tempDir); + + // Move contents to destination. + $filesystem->ensureDirectoryExists($destPath); + $this->moveDirectoryContents($sourceDir, $destPath, $filesystem); + + // Cleanup temp directory. + $filesystem->removeDirectory($tempDir); + } + + /** + * Get the source directory for extraction. + * + * If the extracted archive contains a single root directory, return that + * directory path (to strip the root). Otherwise return the temp directory. + */ + protected function getArchiveSourceDir(string $tempDir): string + { + $entries = array_diff(scandir($tempDir), ['.', '..']); + + // If single entry and it's a directory, use it as source (strip root). + if (count($entries) === 1) { + $entry = reset($entries); + $entryPath = $tempDir . '/' . $entry; + if (is_dir($entryPath)) { + return $entryPath; + } + } + + return $tempDir; + } + + /** + * Move contents from source directory to destination. + */ + protected function moveDirectoryContents(string $source, string $dest, Filesystem $filesystem): void + { + $entries = array_diff(scandir($source), ['.', '..']); + + foreach ($entries as $entry) { + $srcPath = $source . '/' . $entry; + $dstPath = $dest . '/' . $entry; + + if (is_dir($srcPath)) { + $filesystem->ensureDirectoryExists($dstPath); + $this->moveDirectoryContents($srcPath, $dstPath, $filesystem); + @rmdir($srcPath); + } else { + // Remove existing file if any. + if (file_exists($dstPath)) { + $filesystem->unlink($dstPath); + } + rename($srcPath, $dstPath); + } + } + } +} diff --git a/application/data/scripts/install-omeka-assets.php b/application/data/scripts/install-omeka-assets.php new file mode 100644 index 0000000000..2d9bce7dd0 --- /dev/null +++ b/application/data/scripts/install-omeka-assets.php @@ -0,0 +1,226 @@ +#!/usr/bin/env php +isDot() || !$entry->isDir()) { + continue; + } + $paths[$entry->getBasename()] = $entry->getPathname(); + } + } + } + + foreach ($themeDirs as $dir) { + if (is_dir($dir)) { + foreach (new DirectoryIterator($dir) as $entry) { + if ($entry->isDot() || !$entry->isDir()) { + continue; + } + $paths['theme:' . $entry->getBasename()] = $entry->getPathname(); + } + } + } +} else { + foreach ($names as $name) { + if ($isTheme) { + $possiblePaths = [ + OMEKA_PATH . '/themes/' . $name, + OMEKA_PATH . '/addons/themes/' . $name, + ]; + } else { + $possiblePaths = [ + OMEKA_PATH . '/modules/' . $name, + OMEKA_PATH . '/addons/modules/' . $name, + ]; + } + + $found = false; + foreach ($possiblePaths as $path) { + if (is_dir($path)) { + $paths[$name] = $path; + $found = true; + break; + } + } + + if (!$found) { + echo "Error: " . ($isTheme ? 'Theme' : 'Module') . " '$name' not found.\n"; + exit(1); + } + } +} + +// Process each path +$totalInstalled = 0; +$totalSkipped = 0; +$totalFailed = 0; + +foreach ($paths as $name => $path) { + $composerJson = $path . '/composer.json'; + if (!file_exists($composerJson)) { + continue; + } + + $json = json_decode(file_get_contents($composerJson), true); + if (empty($json['extra']['omeka-assets'])) { + continue; + } + + echo "Processing $name...\n"; + + if ($force) { + // Remove existing assets to force re-download + foreach ($json['extra']['omeka-assets'] as $dest => $url) { + $destPath = $path . '/' . ltrim($dest, '/'); + if (substr($dest, -1) === '/') { + if (is_dir($destPath)) { + echo " Removing $dest for re-download\n"; + removeDirectory($destPath); + } + } else { + if (file_exists($destPath)) { + echo " Removing $dest for re-download\n"; + unlink($destPath); + } + } + } + } + + $result = $installer->installFromPath($path, $name); + + if ($result) { + $totalInstalled++; + } else { + $totalFailed++; + } +} + +echo "\nDone. Installed: $totalInstalled, Failed: $totalFailed\n"; + +exit($totalFailed > 0 ? 1 : 0); + +/** + * Recursively remove a directory. + */ +function removeDirectory(string $dir): void +{ + if (!is_dir($dir)) { + return; + } + $entries = array_diff(scandir($dir), ['.', '..']); + foreach ($entries as $entry) { + $path = $dir . '/' . $entry; + if (is_dir($path)) { + removeDirectory($path); + } else { + @unlink($path); + } + } + @rmdir($dir); +} diff --git a/application/src/Module/OmekaAssetsInstaller.php b/application/src/Module/OmekaAssetsInstaller.php new file mode 100644 index 0000000000..878d64ad20 --- /dev/null +++ b/application/src/Module/OmekaAssetsInstaller.php @@ -0,0 +1,343 @@ +logger = $logger; + } + + /** + * Install omeka-assets for a module. + * + * @param Module $module The module to install assets for + * @return bool True if all assets were installed successfully + */ + public function install(Module $module): bool + { + $modulePath = dirname($module->getModuleFilePath()); + return $this->installFromPath($modulePath, $module->getId()); + } + + /** + * Install omeka-assets from a module/theme path. + * + * @param string $path Path to the module or theme directory + * @param string|null $name Optional name for logging + * @return bool True if all assets were installed successfully + */ + public function installFromPath(string $path, ?string $name = null): bool + { + $composerJsonPath = $path . '/composer.json'; + if (!file_exists($composerJsonPath)) { + return true; // No composer.json, nothing to do + } + + $composerJson = json_decode(file_get_contents($composerJsonPath), true); + if (!$composerJson) { + return true; // Invalid JSON, skip + } + + $omekaAssets = $composerJson['extra']['omeka-assets'] ?? null; + if (!$omekaAssets || !is_array($omekaAssets)) { + return true; // No omeka-assets defined + } + + $name = $name ?: basename($path); + $success = true; + + foreach ($omekaAssets as $destination => $url) { + $destPath = $path . '/' . ltrim($destination, '/'); + + // Check if asset already exists + if ($this->assetExists($destPath, $destination)) { + $this->logger->info(sprintf( + 'Asset already exists for %s: %s', + $name, + $destination + )); + continue; + } + + $this->logger->info(sprintf( + 'Downloading asset for %s: %s', + $name, + basename($url) + )); + + try { + $isDirectory = substr($destination, -1) === '/'; + $isArchive = (bool) preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); + + if ($isDirectory && $isArchive) { + $this->downloadAndExtract($url, $destPath); + } elseif ($isDirectory) { + $this->downloadFile($url, $destPath . basename($url)); + } else { + $this->downloadFile($url, $destPath); + } + } catch (\Exception $e) { + $this->logger->err(sprintf( + 'Failed to download asset %s for %s: %s', + $url, + $name, + $e->getMessage() + )); + $success = false; + } + } + + return $success; + } + + /** + * Check if an asset already exists. + */ + protected function assetExists(string $destPath, string $destination): bool + { + $isDirectory = substr($destination, -1) === '/'; + + if ($isDirectory) { + // For directories, check if the directory exists and is not empty + if (is_dir($destPath)) { + $entries = array_diff(scandir($destPath), ['.', '..']); + return count($entries) > 0; + } + return false; + } + + return file_exists($destPath); + } + + /** + * Download a single file. + */ + protected function downloadFile(string $url, string $destPath): void + { + $destDir = dirname($destPath); + if (!is_dir($destDir)) { + mkdir($destDir, 0755, true); + } + + $content = $this->fetchUrl($url); + file_put_contents($destPath, $content); + } + + /** + * Download and extract an archive. + */ + protected function downloadAndExtract(string $url, string $destPath): void + { + $tempFile = sys_get_temp_dir() . '/' . basename($url); + $tempDir = sys_get_temp_dir() . '/omeka_extract_' . uniqid(); + + mkdir($tempDir, 0755, true); + + try { + $content = $this->fetchUrl($url); + file_put_contents($tempFile, $content); + + if (preg_match('/\.zip$/i', $url)) { + $this->extractZip($tempFile, $tempDir); + } elseif (preg_match('/\.(tar\.gz|tgz)$/i', $url)) { + $this->extractTarGz($tempFile, $tempDir); + } + + @unlink($tempFile); + + // Check if archive has a single root directory and strip it + $sourceDir = $this->getArchiveSourceDir($tempDir); + + // Move contents to destination + if (!is_dir($destPath)) { + mkdir($destPath, 0755, true); + } + $this->moveDirectoryContents($sourceDir, $destPath); + + // Cleanup temp directory + $this->removeDirectory($tempDir); + } catch (\Exception $e) { + @unlink($tempFile); + $this->removeDirectory($tempDir); + throw $e; + } + } + + /** + * Fetch URL content. + */ + protected function fetchUrl(string $url): string + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: Omeka S\r\n", + 'follow_location' => true, + 'timeout' => 30, + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + + $content = @file_get_contents($url, false, $context); + if ($content === false) { + throw new \RuntimeException('Failed to download: ' . $url); + } + + return $content; + } + + /** + * Extract a zip file. + */ + protected function extractZip(string $zipFile, string $destDir): void + { + // Try command line first + $command = sprintf( + 'unzip -o -q %s -d %s 2>&1', + escapeshellarg($zipFile), + escapeshellarg($destDir) + ); + exec($command, $output, $exitCode); + + if ($exitCode === 0) { + return; + } + + // Fallback to ZipArchive + if (!class_exists('ZipArchive')) { + throw new \RuntimeException('Cannot extract zip: unzip command failed and ZipArchive not available'); + } + + $zip = new \ZipArchive(); + if ($zip->open($zipFile) !== true) { + throw new \RuntimeException('Failed to open zip archive'); + } + $zip->extractTo($destDir); + $zip->close(); + } + + /** + * Extract a tar.gz file. + */ + protected function extractTarGz(string $tarFile, string $destDir): void + { + // Try command line first + $command = sprintf( + 'tar -xzf %s -C %s 2>&1', + escapeshellarg($tarFile), + escapeshellarg($destDir) + ); + exec($command, $output, $exitCode); + + if ($exitCode === 0) { + return; + } + + // Fallback to PharData + $phar = new \PharData($tarFile); + $phar->extractTo($destDir); + } + + /** + * Get the source directory for extraction. + * + * If the extracted archive contains a single root directory, return that + * directory path (to strip the root). Otherwise return the temp directory. + */ + protected function getArchiveSourceDir(string $tempDir): string + { + $entries = array_diff(scandir($tempDir), ['.', '..']); + + // If single entry and it's a directory, use it as source (strip root) + if (count($entries) === 1) { + $entry = reset($entries); + $entryPath = $tempDir . '/' . $entry; + if (is_dir($entryPath)) { + return $entryPath; + } + } + + return $tempDir; + } + + /** + * Move contents from source directory to destination. + */ + protected function moveDirectoryContents(string $source, string $dest): void + { + $entries = array_diff(scandir($source), ['.', '..']); + + foreach ($entries as $entry) { + $srcPath = $source . '/' . $entry; + $dstPath = $dest . '/' . $entry; + + if (is_dir($srcPath)) { + if (!is_dir($dstPath)) { + mkdir($dstPath, 0755, true); + } + $this->moveDirectoryContents($srcPath, $dstPath); + @rmdir($srcPath); + } else { + if (file_exists($dstPath)) { + @unlink($dstPath); + } + rename($srcPath, $dstPath); + } + } + } + + /** + * Recursively remove a directory. + */ + protected function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $entries = array_diff(scandir($dir), ['.', '..']); + foreach ($entries as $entry) { + $path = $dir . '/' . $entry; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + @unlink($path); + } + } + @rmdir($dir); + } +} diff --git a/composer.json b/composer.json index 30419bc499..78d36f56bd 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "laminas/laminas-validator": "^2.8", "laminas/laminas-view": "^2.8", "omeka/composer-addon-installer": "*", + "omeka/omeka-assets": "*", "omeka-s-themes/default": "dev-develop", "beberlei/doctrineextensions": "^1.0", "fileeye/pel": "^0.12.0" @@ -65,7 +66,8 @@ "config": { "platform": {"php": "8.1"}, "allow-plugins": { - "omeka/composer-addon-installer": true + "omeka/composer-addon-installer": true, + "omeka/omeka-assets": true } }, "repositories": [ @@ -81,6 +83,10 @@ "type": "path", "url": "application/data/composer-addon-installer" }, + { + "type": "path", + "url": "application/data/omeka-assets" + }, { "type": "vcs", "url": "https://github.com/omeka/laminas-log" diff --git a/composer.lock b/composer.lock index 0d80e1654d..50410db9fc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "837a91aad99679c51b33ad90066fb965", + "content-hash": "c02e28af91d05db693f83bab5b1ccd2d", "packages": [ { "name": "beberlei/doctrineextensions", @@ -4254,6 +4254,38 @@ "relative": true } }, + { + "name": "omeka/omeka-assets", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "application/data/omeka-assets", + "reference": "c288c28e6b6894aa641f63880531f0ecb7147392" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": ">=8.1" + }, + "require-dev": { + "composer/composer": "^2.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Omeka\\OmekaAssets\\OmekaAssetsPlugin" + }, + "autoload": { + "psr-4": { + "Omeka\\OmekaAssets\\": "src/" + } + }, + "license": [ + "GPL-3.0-or-later" + ], + "description": "Composer plugin to download external assets for Omeka S modules and themes", + "transport-options": { + "relative": true + } + }, { "name": "psr/cache", "version": "3.0.0", From 3ea5d4a72c2eac79c99edaa1449c4c1cf0f0ac20 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 14/26] Added script to install add-on dependencies manually. --- addons/README.md | 45 +++++- .../data/scripts/install-addon-deps.php | 142 ++++++++++++++++++ .../data/scripts/install-omeka-assets.php | 9 +- 3 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 application/data/scripts/install-addon-deps.php diff --git a/addons/README.md b/addons/README.md index 8a098ca347..4eb23e521c 100644 --- a/addons/README.md +++ b/addons/README.md @@ -98,22 +98,51 @@ Omeka, that should be manageable on a server without nodejs. The assets feature is provided by the internal separate composer plugin `omeka/omeka-assets`. -### Manual installation (git clone) -For modules installed via git clone, assets are not downloaded automatically. -Use the script to install them (adapt path according to current directory): +Manual installation +------------------- -```bash -# Install assets for a specific module. +For add-ons installed manually via `git clone` in directory `modules/` or +`themes/`, dependencies and assets are not downloaded automatically. Use the +following scripts from the Omeka root: + +```sh +# 1. Clone the add-on +git clone https://gitlab.com/user/MyModule modules/MyModule + +# 2. Install composer dependencies (other modules, libraries) +php application/data/scripts/install-addon-deps.php MyModule + +# 3. Install external assets (js, css, etc.) +php application/data/scripts/install-omeka-assets.php MyModule +``` + +### Install dependencies + +```sh +# Module +php application/data/scripts/install-addon-deps.php ModuleName + +# Theme +php application/data/scripts/install-addon-deps.php --theme theme-name + +# Preview without installing +php application/data/scripts/install-addon-deps.php --dry-run ModuleName +``` + +### Install assets + +```sh +# Module php application/data/scripts/install-omeka-assets.php ModuleName -# Install assets for a theme. +# Theme php application/data/scripts/install-omeka-assets.php --theme theme-name -# Install assets for all modules and themes. +# All modules and themes php application/data/scripts/install-omeka-assets.php --all -# Force re-download (even if assets already exist). +# Force re-download php application/data/scripts/install-omeka-assets.php --force ModuleName ``` diff --git a/application/data/scripts/install-addon-deps.php b/application/data/scripts/install-addon-deps.php new file mode 100644 index 0000000000..0d234bc87a --- /dev/null +++ b/application/data/scripts/install-addon-deps.php @@ -0,0 +1,142 @@ +#!/usr/bin/env php + $version) { + // Skip Omeka core packages (provided by Omeka) + if (in_array($package, ['omeka/omeka-s', 'omeka/omeka-s-core'])) { + continue; + } + // Skip PHP extensions + if (strpos($package, 'ext-') === 0) { + continue; + } + // Skip PHP version constraint + if ($package === 'php') { + continue; + } + $toInstall[$package] = $version; +} + +if (empty($toInstall)) { + echo "No external dependencies to install for $addonName.\n"; + exit(0); +} + +echo "Dependencies for $addonName:\n"; +foreach ($toInstall as $package => $version) { + echo " - $package: $version\n"; +} + +if ($dryRun) { + echo "\n[Dry run] Would run:\n"; + echo " composer require " . implode(' ', array_keys($toInstall)) . "\n"; + exit(0); +} + +echo "\nInstalling...\n"; + +$packages = implode(' ', array_map(function ($pkg, $ver) { + // Use version constraint if specific, otherwise let composer decide + if (preg_match('/^\^|~|>=|<=|>|<|\*/', $ver)) { + return escapeshellarg("$pkg:$ver"); + } + return escapeshellarg($pkg); +}, array_keys($toInstall), $toInstall)); + +$command = "cd " . escapeshellarg(dirname(__DIR__, 3)) . " && composer require $packages --no-interaction 2>&1"; + +echo "Running: composer require " . implode(' ', array_keys($toInstall)) . "\n\n"; + +passthru($command, $exitCode); + +exit($exitCode); diff --git a/application/data/scripts/install-omeka-assets.php b/application/data/scripts/install-omeka-assets.php index 2d9bce7dd0..a2cfb29e9f 100644 --- a/application/data/scripts/install-omeka-assets.php +++ b/application/data/scripts/install-omeka-assets.php @@ -1,7 +1,14 @@ #!/usr/bin/env php Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 15/26] Updated tests to tests assets. --- .../OmekaTest/Composer/AddonInstallerTest.php | 335 +-- .../OmekaTest/Composer/OmekaAssetsTest.php | 193 ++ .../Module/OmekaAssetsInstallerTest.php | 253 ++ .../Service/ModuleManagerFactoryTest.php | 43 +- .../Service/ThemeManagerFactoryTest.php | 1 - composer.json | 3 +- composer.lock | 2518 +++++++++++------ 7 files changed, 2172 insertions(+), 1174 deletions(-) create mode 100644 application/test/OmekaTest/Composer/OmekaAssetsTest.php create mode 100644 application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php index 45e2d74fb4..33326d9453 100644 --- a/application/test/OmekaTest/Composer/AddonInstallerTest.php +++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php @@ -2,332 +2,93 @@ namespace OmekaTest\Composer; +use Omeka\Composer\AddonInstaller; use Omeka\Test\TestCase; /** - * Test AddonInstaller for composer addon installation. + * Test AddonInstaller name transformations. * - * Tests: - * - Module and theme name inflection - * - installer-name / install-name handling - * - standalone flag detection - * - * Note: All tests are skipped if the composer/composer package is not - * available (which is normal since it's only needed during composer - * install/update, not at runtime). + * These tests require composer/composer as a dev dependency. + * They are skipped if Composer classes are not available. */ class AddonInstallerTest extends TestCase { protected function setUp(): void { - parent::setUp(); - - // Skip all tests if Composer classes are not available. - // AddonInstaller extends Composer\Installer\LibraryInstaller, - // so the class cannot even be loaded without composer/composer. - if (!class_exists('Composer\Installer\LibraryInstaller')) { - $this->markTestSkipped( - 'Composer classes not available. ' . - 'Run "composer require --dev composer/composer" to enable these tests.' - ); + if (!interface_exists(\Composer\Package\PackageInterface::class)) { + $this->markTestSkipped('Requires composer/composer dev dependency.'); } + parent::setUp(); } /** - * Call protected static method via reflection. - */ - protected function callProtectedMethod(string $method, array $args) - { - $reflection = new \ReflectionClass('Omeka\Composer\AddonInstaller'); - $method = $reflection->getMethod($method); - $method->setAccessible(true); - return $method->invokeArgs(null, $args); - } - - /** - * Create a Composer Package for testing. + * @dataProvider moduleNameProvider */ - protected function createComposerPackage(string $name, string $type, array $extra = []) + public function testInflectModuleName(string $packageName, string $expected): void { - $class = 'Composer\Package\Package'; - $package = new $class($name, '1.0.0', '1.0.0'); - $package->setType($type); - $package->setExtra($extra); - return $package; + $package = $this->createMockPackage($packageName, 'omeka-s-module'); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals($expected, $result); } /** - * Get AddonInstaller class (for calling static methods). + * @dataProvider themeNameProvider */ - protected function getAddonInstallerClass(): string + public function testInflectThemeName(string $packageName, string $expected): void { - return 'Omeka\Composer\AddonInstaller'; + $package = $this->createMockPackage($packageName, 'omeka-s-theme'); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals($expected, $result); } - // ------------------------------------------------------------------------- - // Tests: Module name inflection (protected method) - // ------------------------------------------------------------------------- - - /** - * @dataProvider moduleNameInflectionProvider - */ - public function testInflectModuleName($inputName, $expectedInstallName) + public function testInstallerNameOverride(): void { - $result = $this->callProtectedMethod('inflectModuleName', [$inputName]); - $this->assertEquals($expectedInstallName, $result); + $package = $this->createMockPackage('vendor/some-module', 'omeka-s-module', [ + 'installer-name' => 'CustomName', + ]); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals('CustomName', $result); } - public function moduleNameInflectionProvider() + public function testStandaloneFlag(): void { - return [ - // Standard prefixes (after vendor/project extraction) - ['omeka-s-module-common', 'Common'], - ['omeka-s-module-advanced-search', 'AdvancedSearch'], - ['omeka-module-value-suggest', 'ValueSuggest'], - - // Without prefix - ['value-suggest', 'ValueSuggest'], - ['bulk-import', 'BulkImport'], - - // With suffix - ['bulk-import-module', 'BulkImport'], - ['neatline-omeka-s', 'Neatline'], - ['module-lessonplans', 'Lessonplans'], - - // Mixed - ['omeka-s-module-easy-admin-module', 'EasyAdmin'], - - // Simple names - ['common', 'Common'], - ['mapping', 'Mapping'], - ['csv-import', 'CsvImport'], - - // Edge cases - ['module', 'Module'], - ['omeka-s', ''], - ]; - } - - // ------------------------------------------------------------------------- - // Tests: Theme name inflection (protected method) - // ------------------------------------------------------------------------- + $package = $this->createMockPackage('vendor/module', 'omeka-s-module', [ + 'standalone' => true, + ]); + $this->assertTrue(AddonInstaller::isStandalone($package)); - /** - * @dataProvider themeNameInflectionProvider - */ - public function testInflectThemeName($inputName, $expectedInstallName) - { - $result = $this->callProtectedMethod('inflectThemeName', [$inputName]); - $this->assertEquals($expectedInstallName, $result); + $package2 = $this->createMockPackage('vendor/module', 'omeka-s-module', []); + $this->assertFalse(AddonInstaller::isStandalone($package2)); } - public function themeNameInflectionProvider() + public function moduleNameProvider(): array { return [ - // Standard prefixes (after vendor/project extraction) - ['omeka-s-theme-repository', 'repository'], - ['omeka-s-theme-foundation-s', 'foundation-s'], - ['omeka-theme-flavor', 'flavor'], - - // Without prefix - ['my-custom', 'my-custom'], - ['cozy', 'cozy'], - - // With suffix - ['my-custom-theme', 'my-custom'], - ['flavor-theme-omeka', 'flavor'], - - // Mixed - ['omeka-s-theme-centerrow-theme-omeka-s', 'centerrow'], + ['vendor/value-suggest', 'ValueSuggest'], + ['vendor/omeka-s-module-common', 'Common'], + ['vendor/bulk-import-module', 'BulkImport'], + ['vendor/module-lessonplans-omeka-s', 'Lessonplans'], + ['vendor/neatline-omeka-s', 'Neatline'], + ['daniel-km/omeka-s-module-common', 'Common'], ]; } - // ------------------------------------------------------------------------- - // Tests: isStandalone static method - // ------------------------------------------------------------------------- - - public function testIsStandaloneReturnsTrueWhenSet() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => true]); - $this->assertTrue($class::isStandalone($package)); - } - - public function testIsStandaloneReturnsFalseWhenNotSet() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module'); - $this->assertFalse($class::isStandalone($package)); - } - - public function testIsStandaloneReturnsFalseWhenExplicitlyFalse() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => false]); - $this->assertFalse($class::isStandalone($package)); - } - - public function testIsStandaloneReturnsTrueForTruthyValue() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => 1]); - $this->assertTrue($class::isStandalone($package)); - } - - public function testIsStandaloneReturnsFalseForFalsyValue() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-module', 'omeka-s-module', ['standalone' => 0]); - $this->assertFalse($class::isStandalone($package)); - } - - public function testIsStandaloneWorksForThemes() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('vendor/my-theme', 'omeka-s-theme', ['standalone' => true]); - $this->assertTrue($class::isStandalone($package)); - } - - // ------------------------------------------------------------------------- - // Tests: getInstallName static method - // ------------------------------------------------------------------------- - - public function testInstallerNameOverridesInflection() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage( - 'vendor/some-weird-name', - 'omeka-s-module', - ['installer-name' => 'CustomModuleName'] - ); - $this->assertEquals('CustomModuleName', $class::getInstallName($package)); - } - - public function testInstallNameLegacyOverridesInflection() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage( - 'vendor/some-weird-name', - 'omeka-s-module', - ['install-name' => 'LegacyName'] - ); - $this->assertEquals('LegacyName', $class::getInstallName($package)); - } - - public function testInstallerNameTakesPrecedenceOverInstallName() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage( - 'vendor/some-weird-name', - 'omeka-s-module', - [ - 'installer-name' => 'PreferredName', - 'install-name' => 'LegacyName', - ] - ); - $this->assertEquals('PreferredName', $class::getInstallName($package)); - } - - public function testGetInstallNameThrowsForPackageWithoutSlash() - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('must contain a slash'); - - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage('invalid-package-name', 'omeka-s-module'); - $class::getInstallName($package); - } - - // ------------------------------------------------------------------------- - // Tests: Combined scenarios - // ------------------------------------------------------------------------- - - public function testModuleWithStandaloneAndCustomName() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage( - 'vendor/omeka-s-module-test', - 'omeka-s-module', - [ - 'installer-name' => 'MyCustomModule', - 'standalone' => true, - 'label' => 'My Custom Module', - ] - ); - - $this->assertEquals('MyCustomModule', $class::getInstallName($package)); - $this->assertTrue($class::isStandalone($package)); - } - - public function testThemeWithStandaloneAndCustomName() - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage( - 'vendor/omeka-s-theme-test', - 'omeka-s-theme', - [ - 'installer-name' => 'my-custom-theme', - 'standalone' => true, - ] - ); - - $this->assertEquals('my-custom-theme', $class::getInstallName($package)); - $this->assertTrue($class::isStandalone($package)); - } - - // ------------------------------------------------------------------------- - // Tests: Real-world package names - // ------------------------------------------------------------------------- - - /** - * @dataProvider realWorldModuleNamesProvider - */ - public function testRealWorldModuleNames($packageName, $expectedInstallName) - { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage($packageName, 'omeka-s-module'); - $this->assertEquals($expectedInstallName, $class::getInstallName($package)); - } - - public function realWorldModuleNamesProvider() + public function themeNameProvider(): array { return [ - // Omeka official modules. - ['omeka-s-modules/Mapping', 'Mapping'], - ['omeka-s-modules/Collecting', 'Collecting'], - ['omeka-s-modules/CustomVocab', 'CustomVocab'], - ['omeka-s-modules/FacetedBrowse', 'FacetedBrowse'], - - // Various naming conventions from different developers. - ['daniel-km/omeka-s-module-common', 'Common'], - ['daniel-km/omeka-s-module-easy-admin', 'EasyAdmin'], - ['zerocrates/HideProperties', 'HideProperties'], - ['chnm/Datascribe-module', 'Datascribe'], - ['manondamoon/omeka-s-module-group', 'Group'], + ['vendor/thanks-roy', 'thanks-roy'], + ['vendor/omeka-s-theme-repository', 'repository'], + ['vendor/my-custom-theme-theme', 'my-custom-theme'], + ['vendor/flavor-theme-omeka', 'flavor'], ]; } - /** - * @dataProvider realWorldThemeNamesProvider - */ - public function testRealWorldThemeNames($packageName, $expectedInstallName) + protected function createMockPackage(string $name, string $type, array $extra = []) { - $class = $this->getAddonInstallerClass(); - $package = $this->createComposerPackage($packageName, 'omeka-s-theme'); - $this->assertEquals($expectedInstallName, $class::getInstallName($package)); - } - - public function realWorldThemeNamesProvider() - { - return [ - // Omeka official themes. - ['omeka-s-themes/default', 'default'], - ['omeka-s-themes/cozy', 'cozy'], - ['omeka-s-themes/centerrow', 'centerrow'], - ['omeka-s-themes/thedaily', 'thedaily'], - - // Community themes. - ['daniel-km/omeka-s-theme-repository', 'repository'], - ]; + $package = $this->createMock(\Composer\Package\PackageInterface::class); + $package->method('getPrettyName')->willReturn($name); + $package->method('getType')->willReturn($type); + $package->method('getExtra')->willReturn($extra); + return $package; } } diff --git a/application/test/OmekaTest/Composer/OmekaAssetsTest.php b/application/test/OmekaTest/Composer/OmekaAssetsTest.php new file mode 100644 index 0000000000..46163bd8b6 --- /dev/null +++ b/application/test/OmekaTest/Composer/OmekaAssetsTest.php @@ -0,0 +1,193 @@ +assertEquals($expectDirectory, $isDirectory, "Directory detection for: $destination"); + $this->assertEquals($expectArchive, $isArchive, "Archive detection for: $url"); + } + + public function omekaAssetsProvider(): array + { + return [ + // [destination, url, expectDirectory, expectArchive] + [ + 'asset/vendor/lib/file.min.js', + 'https://example.com/file.min.js', + false, // not a directory + false, // not an archive + ], + [ + 'asset/vendor/lib/', + 'https://example.com/archive.zip', + true, // is a directory + true, // is an archive + ], + [ + 'asset/vendor/mirador/', + 'https://example.com/mirador-2.7.0.tar.gz', + true, // is a directory + true, // is an archive + ], + [ + 'asset/vendor/lib/', + 'https://example.com/file.tgz', + true, // is a directory + true, // is an archive + ], + [ + 'asset/css/custom.css', + 'https://example.com/styles.css', + false, // not a directory + false, // not an archive + ], + // Third case: directory + non-archive = copy file into directory + [ + 'asset/vendor/lib/', + 'https://example.com/jquery.min.js', + true, // is a directory + false, // not an archive → file copied into directory + ], + ]; + } + + /** + * @dataProvider omekaAssetsActionProvider + */ + public function testOmekaAssetsActionDetection(string $destination, string $url, string $expectedAction): void + { + $isDirectory = substr($destination, -1) === '/'; + $isArchive = (bool) preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); + + if ($isDirectory && $isArchive) { + $action = 'extract'; + } elseif ($isDirectory) { + $action = 'copy_into_dir'; + } else { + $action = 'download'; + } + + $this->assertEquals($expectedAction, $action, "Action for: $destination <- $url"); + } + + public function omekaAssetsActionProvider(): array + { + return [ + // [destination, url, expectedAction] + ['asset/vendor/lib/file.min.js', 'https://example.com/file.min.js', 'download'], + ['asset/vendor/lib/', 'https://example.com/archive.zip', 'extract'], + ['asset/vendor/lib/', 'https://example.com/archive.tar.gz', 'extract'], + ['asset/vendor/lib/', 'https://example.com/jquery.min.js', 'copy_into_dir'], + ['asset/vendor/lib/', 'https://example.com/styles.css', 'copy_into_dir'], + ]; + } + + public function testDestinationFilenameRename(): void + { + // When destination has a different filename than the URL, it renames. + $destination = 'asset/vendor/lib/jquery.autocomplete.min.js'; + $url = 'https://example.com/jquery.autocomplete-1.5.0.min.js'; + + // The destination path is used as-is (not the URL basename). + $destPath = '/install/path/' . ltrim($destination, '/'); + $this->assertEquals('/install/path/asset/vendor/lib/jquery.autocomplete.min.js', $destPath); + $this->assertNotEquals(basename($url), basename($destPath)); + } + + public function testArchiveSingleRootDirectoryStripping(): void + { + // Simulate the logic that detects a single root directory. + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/mirador-2.7.0'); + touch($tempDir . '/mirador-2.7.0/file1.js'); + touch($tempDir . '/mirador-2.7.0/file2.js'); + + $entries = array_diff(scandir($tempDir), ['.', '..']); + + // Single entry that is a directory → should be stripped. + $this->assertCount(1, $entries); + $entry = reset($entries); + $this->assertTrue(is_dir($tempDir . '/' . $entry)); + + // The source dir should be the nested directory. + $sourceDir = $tempDir . '/' . $entry; + $this->assertEquals($tempDir . '/mirador-2.7.0', $sourceDir); + + // Cleanup. + unlink($tempDir . '/mirador-2.7.0/file1.js'); + unlink($tempDir . '/mirador-2.7.0/file2.js'); + rmdir($tempDir . '/mirador-2.7.0'); + rmdir($tempDir); + } + + public function testArchiveMultipleEntriesNoStripping(): void + { + // Simulate an archive with multiple root entries. + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + touch($tempDir . '/file1.js'); + touch($tempDir . '/file2.js'); + + $entries = array_diff(scandir($tempDir), ['.', '..']); + + // Multiple entries → no stripping, use tempDir as source. + $this->assertCount(2, $entries); + + // Cleanup. + unlink($tempDir . '/file1.js'); + unlink($tempDir . '/file2.js'); + rmdir($tempDir); + } + + public function testOmekaAssetsConfigParsing(): void + { + $composerJson = [ + 'extra' => [ + 'omeka-assets' => [ + 'asset/vendor/jquery-autocomplete/jquery.autocomplete.min.js' => 'https://example.com/jquery.autocomplete.min.js', + 'asset/vendor/mirador/' => 'https://example.com/mirador.zip', + ], + ], + ]; + + $extra = $composerJson['extra']; + $this->assertArrayHasKey('omeka-assets', $extra); + $this->assertIsArray($extra['omeka-assets']); + $this->assertCount(2, $extra['omeka-assets']); + + foreach ($extra['omeka-assets'] as $destination => $url) { + $this->assertIsString($destination); + $this->assertIsString($url); + $this->assertStringStartsWith('https://', $url); + } + } + + public function testEmptyOmekaAssetsConfig(): void + { + $composerJson = [ + 'extra' => [], + ]; + + $extra = $composerJson['extra']; + $hasAssets = !empty($extra['omeka-assets']) && is_array($extra['omeka-assets']); + $this->assertFalse($hasAssets); + } +} diff --git a/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php b/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php new file mode 100644 index 0000000000..3387ec1512 --- /dev/null +++ b/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php @@ -0,0 +1,253 @@ +createMock(\Laminas\Log\LoggerInterface::class); + return new OmekaAssetsInstaller($logger); + } + + public function testInstallFromPathWithNoComposerJson(): void + { + $installer = $this->getInstaller(); + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + + // No composer.json, should return true (nothing to do) + $result = $installer->installFromPath($tempDir); + $this->assertTrue($result); + + rmdir($tempDir); + } + + public function testInstallFromPathWithEmptyOmekaAssets(): void + { + $installer = $this->getInstaller(); + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + + // Create composer.json without omeka-assets + file_put_contents($tempDir . '/composer.json', json_encode([ + 'name' => 'test/module', + 'extra' => [], + ])); + + $result = $installer->installFromPath($tempDir); + $this->assertTrue($result); + + unlink($tempDir . '/composer.json'); + rmdir($tempDir); + } + + public function testInstallFromPathWithExistingAsset(): void + { + $installer = $this->getInstaller(); + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/asset/vendor', 0755, true); + + // Create a file that already exists + file_put_contents($tempDir . '/asset/vendor/file.js', 'existing'); + + // Create composer.json with omeka-assets pointing to existing file + file_put_contents($tempDir . '/composer.json', json_encode([ + 'name' => 'test/module', + 'extra' => [ + 'omeka-assets' => [ + 'asset/vendor/file.js' => 'https://example.com/file.js', + ], + ], + ])); + + // Should return true (asset exists, skip download) + $result = $installer->installFromPath($tempDir); + $this->assertTrue($result); + + // Verify the file was NOT overwritten (still has original content) + $this->assertEquals('existing', file_get_contents($tempDir . '/asset/vendor/file.js')); + + // Cleanup + unlink($tempDir . '/asset/vendor/file.js'); + unlink($tempDir . '/composer.json'); + rmdir($tempDir . '/asset/vendor'); + rmdir($tempDir . '/asset'); + rmdir($tempDir); + } + + public function testInstallFromPathWithExistingDirectory(): void + { + $installer = $this->getInstaller(); + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/asset/vendor/lib', 0755, true); + + // Create a file in the directory to mark it as non-empty + file_put_contents($tempDir . '/asset/vendor/lib/existing.js', 'content'); + + // Create composer.json with omeka-assets pointing to existing directory + file_put_contents($tempDir . '/composer.json', json_encode([ + 'name' => 'test/module', + 'extra' => [ + 'omeka-assets' => [ + 'asset/vendor/lib/' => 'https://example.com/archive.zip', + ], + ], + ])); + + // Should return true (directory not empty, skip download) + $result = $installer->installFromPath($tempDir); + $this->assertTrue($result); + + // Cleanup + unlink($tempDir . '/asset/vendor/lib/existing.js'); + unlink($tempDir . '/composer.json'); + rmdir($tempDir . '/asset/vendor/lib'); + rmdir($tempDir . '/asset/vendor'); + rmdir($tempDir . '/asset'); + rmdir($tempDir); + } + + public function testAssetExistsForFile(): void + { + $installer = $this->getInstaller(); + + // Use reflection to test protected method + $method = new \ReflectionMethod($installer, 'assetExists'); + $method->setAccessible(true); + + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + + // Non-existent file + $this->assertFalse($method->invoke($installer, $tempDir . '/file.js', 'file.js')); + + // Existent file + file_put_contents($tempDir . '/file.js', 'content'); + $this->assertTrue($method->invoke($installer, $tempDir . '/file.js', 'file.js')); + + // Cleanup + unlink($tempDir . '/file.js'); + rmdir($tempDir); + } + + public function testAssetExistsForDirectory(): void + { + $installer = $this->getInstaller(); + + // Use reflection to test protected method + $method = new \ReflectionMethod($installer, 'assetExists'); + $method->setAccessible(true); + + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + + // Non-existent directory + $this->assertFalse($method->invoke($installer, $tempDir . '/lib/', 'lib/')); + + // Empty directory (should be considered as not existing) + mkdir($tempDir . '/lib'); + $this->assertFalse($method->invoke($installer, $tempDir . '/lib/', 'lib/')); + + // Non-empty directory + file_put_contents($tempDir . '/lib/file.js', 'content'); + $this->assertTrue($method->invoke($installer, $tempDir . '/lib/', 'lib/')); + + // Cleanup + unlink($tempDir . '/lib/file.js'); + rmdir($tempDir . '/lib'); + rmdir($tempDir); + } + + public function testArchiveSourceDirWithSingleRootDirectory(): void + { + $installer = $this->getInstaller(); + + // Use reflection to test protected method + $method = new \ReflectionMethod($installer, 'getArchiveSourceDir'); + $method->setAccessible(true); + + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + mkdir($tempDir . '/mirador-2.7.0'); + touch($tempDir . '/mirador-2.7.0/file1.js'); + + // Single root directory should return that directory (for stripping) + $result = $method->invoke($installer, $tempDir); + $this->assertEquals($tempDir . '/mirador-2.7.0', $result); + + // Cleanup + unlink($tempDir . '/mirador-2.7.0/file1.js'); + rmdir($tempDir . '/mirador-2.7.0'); + rmdir($tempDir); + } + + public function testArchiveSourceDirWithMultipleEntries(): void + { + $installer = $this->getInstaller(); + + // Use reflection to test protected method + $method = new \ReflectionMethod($installer, 'getArchiveSourceDir'); + $method->setAccessible(true); + + $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); + mkdir($tempDir); + touch($tempDir . '/file1.js'); + touch($tempDir . '/file2.js'); + + // Multiple entries should return the tempDir itself + $result = $method->invoke($installer, $tempDir); + $this->assertEquals($tempDir, $result); + + // Cleanup + unlink($tempDir . '/file1.js'); + unlink($tempDir . '/file2.js'); + rmdir($tempDir); + } + + public function testMoveDirectoryContents(): void + { + $installer = $this->getInstaller(); + + // Use reflection to test protected method + $method = new \ReflectionMethod($installer, 'moveDirectoryContents'); + $method->setAccessible(true); + + $srcDir = sys_get_temp_dir() . '/omeka_test_src_' . uniqid(); + $dstDir = sys_get_temp_dir() . '/omeka_test_dst_' . uniqid(); + + mkdir($srcDir); + mkdir($srcDir . '/subdir'); + file_put_contents($srcDir . '/file1.js', 'content1'); + file_put_contents($srcDir . '/subdir/file2.js', 'content2'); + + mkdir($dstDir); + + $method->invoke($installer, $srcDir, $dstDir); + + // Check files were moved + $this->assertFileExists($dstDir . '/file1.js'); + $this->assertFileExists($dstDir . '/subdir/file2.js'); + $this->assertEquals('content1', file_get_contents($dstDir . '/file1.js')); + $this->assertEquals('content2', file_get_contents($dstDir . '/subdir/file2.js')); + + // Cleanup + unlink($dstDir . '/file1.js'); + unlink($dstDir . '/subdir/file2.js'); + rmdir($dstDir . '/subdir'); + rmdir($dstDir); + // srcDir should be empty now (files moved) + @rmdir($srcDir . '/subdir'); + @rmdir($srcDir); + } +} diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php index 291c8b5f90..cdb734ef31 100644 --- a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -2,7 +2,6 @@ namespace OmekaTest\Service; -use Omeka\Module\Manager as ModuleManager; use Omeka\Module\InfoReader; use Omeka\Service\ModuleManagerFactory; use Omeka\Test\TestCase; @@ -579,34 +578,28 @@ public function testInstallerNameDerivedFromProjectName() */ public function testLocalModuleInfoTakesPrecedenceOverInstalledJson() { - // Use the test fixtures which have an entry in installed.json. + // Use the test fixtures directory. + $fixturesPath = __DIR__ . '/../Module/fixtures'; $moduleName = 'TestAddonOverride'; - $localModulePath = OMEKA_PATH . '/modules/' . $moduleName; - $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; - - // Skip if test fixtures aren't set up. - if (!is_dir($localModulePath) || !is_dir($addonsModulePath)) { - $this->markTestSkipped( - 'Test fixtures not available. Run the test setup to create ' . - 'TestAddonOverride in both modules/ and addons/modules/.' - ); - } + $localModulePath = $fixturesPath . '/modules/' . $moduleName; + $addonsModulePath = $fixturesPath . '/addons/modules/' . $moduleName; - // Clear the InfoReader cache to ensure fresh reads. - InfoReader::clearCache(); + // Verify fixtures exist. + $this->assertDirectoryExists($localModulePath, 'Local module fixture should exist'); + $this->assertDirectoryExists($addonsModulePath, 'Addons module fixture should exist'); $infoReader = new InfoReader(); - // The installed.json has "Test Addon Override (Composer version)". - $installedInfo = $infoReader->getFromComposerInstalled($moduleName, 'module'); - $this->assertNotNull($installedInfo, 'Module should exist in installed.json'); - $this->assertStringContainsString('Composer', $installedInfo['name']); - // The local module.ini has "Test Addon Override (Local version)". $localInfo = $infoReader->read($localModulePath, 'module'); $this->assertNotNull($localInfo, 'Local module should be readable'); $this->assertStringContainsString('Local', $localInfo['name']); + // The addons module has "Test Addon Override (Composer version)" in composer.json. + $addonsInfo = $infoReader->read($addonsModulePath, 'module'); + $this->assertNotNull($addonsInfo, 'Addons module should be readable'); + $this->assertStringContainsString('Composer', $addonsInfo['name']); + // Test the path-based decision logic used by ModuleManagerFactory. // For a module in modules/, strpos should NOT find '/addons/modules/'. $this->assertFalse( @@ -624,19 +617,13 @@ public function testLocalModuleInfoTakesPrecedenceOverInstalledJson() $isComposerAddon = strpos($localModulePath, '/addons/modules/') !== false; $this->assertFalse($isComposerAddon, 'Local module should not be treated as composer addon'); - // The correct info should come from read(), not getFromComposerInstalled(). - $info = null; - if ($isComposerAddon) { - $info = $infoReader->getFromComposerInstalled($moduleName, 'module'); - } - if (empty($info)) { - $info = $infoReader->read($localModulePath, 'module'); - } + // The correct info should come from read() of the local path. + $info = $infoReader->read($localModulePath, 'module'); $this->assertStringContainsString( 'Local', $info['name'], - 'Module info should come from local module.ini, not installed.json. ' . + 'Module info should come from local module.ini, not composer.json. ' . 'Got: ' . $info['name'] ); } diff --git a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php index d202b49031..7267823b75 100644 --- a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php @@ -3,7 +3,6 @@ namespace OmekaTest\Service; use Omeka\Module\InfoReader; -use Omeka\Site\Theme\Manager as ThemeManager; use Omeka\Service\ThemeManagerFactory; use Omeka\Test\TestCase; diff --git a/composer.json b/composer.json index 78d36f56bd..9c750bde11 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,8 @@ "omeka/omeka-assets": "*", "omeka-s-themes/default": "dev-develop", "beberlei/doctrineextensions": "^1.0", - "fileeye/pel": "^0.12.0" + "fileeye/pel": "^0.12.0", + "composer/composer": "^2.9" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index 50410db9fc..21a5658d60 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c02e28af91d05db693f83bab5b1ccd2d", + "content-hash": "912a4912f08aed45284d057b26542fc1", "packages": [ { "name": "beberlei/doctrineextensions", @@ -117,6 +117,408 @@ ], "time": "2025-02-20T17:42:39+00:00" }, + { + "name": "composer/ca-bundle", + "version": "1.5.10", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", + "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8 || ^9", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.5.10" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-12-08T15:06:51+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.7.1", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/8f5fa3cc214230e71f54924bd0197a3bcc705eb1", + "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpunit/phpunit": "^8", + "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.7.1" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-12-29T13:15:25+00:00" + }, + { + "name": "composer/composer", + "version": "2.9.5", + "source": { + "type": "git", + "url": "https://github.com/composer/composer.git", + "reference": "72a8f8e653710e18d83e5dd531eb5a71fc3223e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/composer/zipball/72a8f8e653710e18d83e5dd531eb5a71fc3223e6", + "reference": "72a8f8e653710e18d83e5dd531eb5a71fc3223e6", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.5", + "composer/class-map-generator": "^1.4.0", + "composer/metadata-minifier": "^1.0", + "composer/pcre": "^2.3 || ^3.3", + "composer/semver": "^3.3", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "ext-json": "*", + "justinrainbow/json-schema": "^6.5.1", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^3.3", + "seld/jsonlint": "^1.4", + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", + "symfony/polyfill-php84": "^1.30", + "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.8", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "phpstan/phpstan-symfony": "^1.4.0", + "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" + }, + "suggest": { + "ext-curl": "Provides HTTP support (will fallback to PHP streams if missing)", + "ext-openssl": "Enables access to repositories and packages over HTTPS", + "ext-zip": "Allows direct extraction of ZIP archives (unzip/7z binaries will be used instead if available)", + "ext-zlib": "Enables gzip for HTTP requests" + }, + "bin": [ + "bin/composer" + ], + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] + }, + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\": "src/Composer/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "https://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", + "homepage": "https://getcomposer.org/", + "keywords": [ + "autoload", + "dependency", + "package" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/composer/issues", + "security": "https://github.com/composer/composer/security/policy", + "source": "https://github.com/composer/composer/tree/2.9.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-01-29T10:40:53+00:00" + }, + { + "name": "composer/metadata-minifier", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/composer/metadata-minifier.git", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", + "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2", + "phpstan/phpstan": "^0.12.55", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\MetadataMinifier\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Small utility library that handles metadata minification and expansion.", + "keywords": [ + "composer", + "compression" + ], + "support": { + "issues": "https://github.com/composer/metadata-minifier/issues", + "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2021-04-07T13:37:33+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -194,6 +596,152 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "composer/spdx-licenses", + "version": "1.5.9", + "source": { + "type": "git", + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Spdx\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2025-05-12T21:07:07+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, { "name": "doctrine/annotations", "version": "1.14.4", @@ -1345,17 +1893,92 @@ "role": "Developer" } ], - "description": "PHP Exif Library. A library for reading and writing Exif headers in JPEG and TIFF images using PHP.", - "homepage": "https://github.com/FileEye/pel", + "description": "PHP Exif Library. A library for reading and writing Exif headers in JPEG and TIFF images using PHP.", + "homepage": "https://github.com/FileEye/pel", + "keywords": [ + "exif", + "image" + ], + "support": { + "issues": "https://github.com/FileEye/pel/issues", + "source": "https://github.com/FileEye/pel/tree/0.12.0" + }, + "time": "2025-01-17T21:19:20+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.6.4", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.4", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "^23.2", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", "keywords": [ - "exif", - "image" + "json", + "schema" ], "support": { - "issues": "https://github.com/FileEye/pel/issues", - "source": "https://github.com/FileEye/pel/tree/0.12.0" + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" }, - "time": "2025-01-17T21:19:20+00:00" + "time": "2025-12-19T15:01:32+00:00" }, { "name": "laminas/laminas-authentication", @@ -4038,6 +4661,79 @@ "abandoned": true, "time": "2023-11-24T13:56:19+00:00" }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" + } + ], + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" + }, { "name": "ml/iri", "version": "1.1.4", @@ -4320,42 +5016,273 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.2" + }, + "time": "2021-11-05T16:50:12+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Common interface for caching libraries", + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "cache", - "psr", - "psr-6" + "promise", + "promises" ], "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, - "time": "2021-02-03T23:26:27+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" }, { - "name": "psr/container", - "version": "1.1.2", + "name": "seld/jsonlint", + "version": "1.11.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + "url": "https://github.com/Seldaek/jsonlint.git", + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": "^5.3 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" }, + "bin": [ + "bin/jsonlint" + ], "type": "library", "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "Seld\\JsonLint\\": "src/Seld/JsonLint/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4364,51 +5291,60 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "JSON Linter", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "json", + "linter", + "parser", + "validator" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" }, - "time": "2021-11-05T16:50:12+00:00" + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2024-07-11T14:55:45+00:00" }, { - "name": "psr/http-message", - "version": "2.0", + "name": "seld/phar-utils", + "version": "1.2.1", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": ">=5.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Http\\Message\\": "src/" + "Seld\\PharUtils\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4417,51 +5353,54 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", + "description": "PHAR file format utilities, for when PHP phars you up", "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" + "phar" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" }, - "time": "2023-04-04T09:54:51+00:00" + "time": "2022-08-31T10:31:18+00:00" }, { - "name": "psr/log", - "version": "1.1.4", + "name": "seld/signal-handler", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", + "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-main": "2.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Seld\\Signal\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4470,21 +5409,24 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", "keywords": [ - "log", - "psr", - "psr-3" + "posix", + "sigint", + "signal", + "sigterm", + "unix" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2023-09-03T09:24:00+00:00" }, { "name": "sweetrdf/easyrdf", @@ -4782,18 +5724,151 @@ "php": ">=8.1" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v6.4.24", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.4.24" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-10T08:14:14+00:00" + }, + { + "name": "symfony/finder", + "version": "v5.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "63741784cd7b9967975eec610b256eed3ede022b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", "autoload": { - "files": [ - "function.php" + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4802,18 +5877,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/finder/tree/v5.4.45" }, "funding": [ { @@ -4829,7 +5904,7 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2024-09-28T13:32:08+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5318,6 +6393,86 @@ ], "time": "2024-09-09T11:45:10+00:00" }, + { + "name": "symfony/polyfill-php73", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.33.0", @@ -5402,6 +6557,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.33.0", @@ -5417,24 +6652,95 @@ "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v6.4.26", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "shasum": "" + }, + "require": { + "php": ">=8.1" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" + "Symfony\\Component\\Process\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5443,24 +6749,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/process/tree/v6.4.26" }, "funding": [ { @@ -5480,7 +6780,7 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/service-contracts", @@ -5879,151 +7179,6 @@ ], "time": "2022-12-23T10:58:28+00:00" }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, - { - "name": "composer/xdebug-handler", - "version": "3.0.5", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", - "shasum": "" - }, - "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" - }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -7303,81 +8458,8 @@ }, { "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], - "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2023-11-13T13:48:05+00:00" - }, - { - "name": "react/promise", - "version": "v3.3.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", - "shasum": "" - }, - "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" }, { "name": "Chris Boden", @@ -7385,14 +8467,14 @@ "homepage": "https://cboden.dev/" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", "keywords": [ - "promise", - "promises" + "asynchronous", + "event-loop" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" }, "funding": [ { @@ -7400,7 +8482,7 @@ "type": "open_collective" } ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2023-11-13T13:48:05+00:00" }, { "name": "react/socket", @@ -8232,260 +9314,21 @@ "dist": { "type": "zip", "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", - "shasum": "" - }, - "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:12:34+00:00" - }, - { - "name": "sebastian/object-reflector", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:14:26+00:00" - }, - { - "name": "sebastian/recursion-context", - "version": "4.0.6", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", - "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", - "type": "tidelift" - } - ], - "time": "2025-08-10T06:57:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-03-14T16:00:52+00:00" - }, - { - "name": "sebastian/type", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -8500,15 +9343,14 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" }, "funding": [ { @@ -8516,29 +9358,32 @@ "type": "github" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2020-10-26T13:12:34+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "sebastian/object-reflector", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", "shasum": "" }, "require": { "php": ">=7.3" }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "2.0-dev" } }, "autoload": { @@ -8553,15 +9398,14 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" }, "funding": [ { @@ -8569,334 +9413,267 @@ "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2020-10-26T13:14:26+00:00" }, { - "name": "symfony/css-selector", - "version": "v6.4.24", + "name": "sebastian/recursion-context", + "version": "4.0.6", "source": { "type": "git", - "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\CssSelector\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "Converts CSS selectors to XPath expressions", - "homepage": "https://symfony.com", + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://github.com/fabpot", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { - "name": "symfony/dom-crawler", - "version": "v6.4.25", + "name": "sebastian/resource-operations", + "version": "3.0.4", "source": { "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", "shasum": "" }, "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=7.3" }, "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "phpunit/phpunit": "^9.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2025-08-05T18:56:08+00:00" + "time": "2024-03-14T16:00:52+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v6.4.25", + "name": "sebastian/type", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b0cf3162020603587363f0551cd3be43958611ff" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", - "reference": "b0cf3162020603587363f0551cd3be43958611ff", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/event-dispatcher-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" + "php": ">=7.3" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "phpunit/phpunit": "^9.5" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, "autoload": { - "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", - "homepage": "https://symfony.com", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, + "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" + "url": "https://github.com/sebastianbergmann", + "type": "github" } ], - "time": "2025-08-13T09:41:44+00:00" + "time": "2023-02-03T06:13:03+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "name": "sebastian/version", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=7.3" }, "type": "library", "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-master": "3.0-dev" } }, "autoload": { - "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Generic abstractions related to dispatching event", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { - "name": "symfony/filesystem", + "name": "symfony/css-selector", "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" + "url": "https://github.com/symfony/css-selector.git", + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "php": ">=8.1" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Component\\CssSelector\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -8911,15 +9688,19 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.24" + "source": "https://github.com/symfony/css-selector/tree/v6.4.24" }, "funding": [ { @@ -8942,28 +9723,32 @@ "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/finder", - "version": "v5.4.45", + "name": "symfony/dom-crawler", + "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "63741784cd7b9967975eec610b256eed3ede022b" + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", - "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\DomCrawler\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -8983,10 +9768,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.45" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" }, "funding": [ { @@ -8997,35 +9782,57 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-28T13:32:08+00:00" + "time": "2025-08-05T18:56:08+00:00" }, { - "name": "symfony/options-resolver", + "name": "symfony/event-dispatcher", "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "b0cf3162020603587363f0551cd3be43958611ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d28e7e2db8a73e9511df892d36445f61314bbebe", - "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", + "reference": "b0cf3162020603587363f0551cd3be43958611ff", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" + "Symfony\\Component\\EventDispatcher\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9045,15 +9852,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an improved replacement for the array_replace PHP function", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.4.25" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" }, "funding": [ { @@ -9073,42 +9875,40 @@ "type": "tidelift" } ], - "time": "2025-08-04T17:06:28+00:00" + "time": "2025-08-13T09:41:44+00:00" }, { - "name": "symfony/polyfill-php81", - "version": "v1.33.0", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -9124,16 +9924,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { @@ -9144,38 +9946,35 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/process", - "version": "v6.4.26", + "name": "symfony/options-resolver", + "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d28e7e2db8a73e9511df892d36445f61314bbebe", + "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Component\\OptionsResolver\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9195,10 +9994,15 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Provides an improved replacement for the array_replace PHP function", "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], "support": { - "source": "https://github.com/symfony/process/tree/v6.4.26" + "source": "https://github.com/symfony/options-resolver/tree/v6.4.25" }, "funding": [ { @@ -9218,7 +10022,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-08-04T17:06:28+00:00" }, { "name": "symfony/stopwatch", From 7b6b37b9d352b57228e089abf7b75a92cb6ba3fd Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 16/26] Fixed override of Common and modules installed with composer. --- .../src/AddonInstallerPlugin.php | 90 ++++++++++++++++++- bootstrap.php | 53 +++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index d97844d648..d458f6129d 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -46,25 +46,111 @@ public static function getSubscribedEvents() return [ PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', + PackageEvents::PRE_PACKAGE_UNINSTALL => 'onPrePackageUninstall', ]; } /** - * Handle post-install for standalone packages. + * Handle pre-uninstall for packages. + */ + public function onPrePackageUninstall(PackageEvent $event) + { + $package = $event->getOperation()->getPackage(); + $this->removeCommonModuleSymlink($package); + } + + /** + * Remove Common module symlink when uninstalling. + */ + protected function removeCommonModuleSymlink($package) + { + if ($package->getName() !== 'daniel-km/omeka-s-module-common') { + return; + } + + $localPath = 'modules/Common'; + + if (is_link($localPath)) { + if (@unlink($localPath)) { + $this->io->write(sprintf( + 'Removed symlink %s', + $localPath + )); + } + } + } + + /** + * Handle post-install for packages. */ public function onPostPackageInstall(PackageEvent $event) { $package = $event->getOperation()->getPackage(); $this->handleStandalonePackage($package); + $this->handleCommonModuleSymlink($package); } /** - * Handle post-update for standalone packages. + * Handle post-update for packages. */ public function onPostPackageUpdate(PackageEvent $event) { $package = $event->getOperation()->getTargetPackage(); $this->handleStandalonePackage($package); + $this->handleCommonModuleSymlink($package); + } + + /** + * Create symlink for Common module if not present in modules/. + * + * Common module is a special case: many modules depend on its root-level + * files (TraitModule.php, etc.) via require_once with file paths like: + * require_once dirname(__DIR__) . '/Common/TraitModule.php'; + * + * When Common is installed via Composer to addons/modules/Common/, this + * path doesn't work. A symlink modules/Common -> addons/modules/Common + * ensures backward compatibility. + */ + protected function handleCommonModuleSymlink($package) + { + // Only handle Common module + if ($package->getName() !== 'daniel-km/omeka-s-module-common') { + return; + } + + $localPath = 'modules/Common'; + + // Don't create symlink if a real directory exists (local override) + if (is_dir($localPath) && !is_link($localPath)) { + return; + } + + $installPath = $this->composer->getInstallationManager()->getInstallPath($package); + $relativePath = '../' . $installPath; + + // Update existing symlink if target changed + if (is_link($localPath)) { + $currentTarget = readlink($localPath); + if ($currentTarget === $relativePath) { + return; + } + unlink($localPath); + } + + // Create symlink + if (@symlink($relativePath, $localPath)) { + $this->io->write(sprintf( + 'Created symlink %s -> %s for backward compatibility', + $localPath, + $relativePath + )); + } else { + $this->io->writeError(sprintf( + 'Could not create symlink %s -> %s', + $localPath, + $relativePath + )); + } } /** diff --git a/bootstrap.php b/bootstrap.php index 66f55b7711..4f152476c3 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -4,3 +4,56 @@ date_default_timezone_set('UTC'); require 'vendor/autoload.php'; + +/* + * Autoloader to prioritize local modules/ over Composer's addons/modules/. + * + * When a module exists in both locations, this ensures classes are loaded + * from modules/ (local) instead of addons/modules/ (Composer). + * + * Registered AFTER Composer with prepend=true to run BEFORE Composer. + */ +spl_autoload_register(function ($class) { + // Extract module namespace (first segment) + $pos = strpos($class, '\\'); + if ($pos === false) { + return false; + } + $moduleNamespace = substr($class, 0, $pos); + + // Check for conflict: module exists in both locations + $localModule = OMEKA_PATH . '/modules/' . $moduleNamespace; + $addonModule = OMEKA_PATH . '/addons/modules/' . $moduleNamespace; + + // Only intervene if local exists as real dir (not symlink) AND addon exists + if (!is_dir($localModule) || is_link($localModule) || !is_dir($addonModule)) { + return false; // No conflict, let Composer handle it + } + + // Special case: Common module has root-level classes + if ($moduleNamespace === 'Common') { + static $commonRootClasses = [ + 'Common\\AbstractModule' => 'AbstractModule.php', + 'Common\\ManageModuleAndResources' => 'ManageModuleAndResources.php', + 'Common\\TraitModule' => 'TraitModule.php', + ]; + if (isset($commonRootClasses[$class])) { + $file = $localModule . '/' . $commonRootClasses[$class]; + if (file_exists($file)) { + require_once $file; + return true; + } + } + } + + // PSR-4: ModuleName\Foo\Bar -> modules/ModuleName/src/Foo/Bar.php + $relativePath = str_replace('\\', '/', substr($class, $pos + 1)); + $file = $localModule . '/src/' . $relativePath . '.php'; + + if (file_exists($file)) { + require_once $file; + return true; + } + + return false; +}, true, true); // prepend=true to run BEFORE Composer From c739d30bf3dfcf1173da8c67a021c4c455ae2454 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 17/26] Added tests for override. --- .../test/OmekaTest/Module/AutoloaderTest.php | 262 ++++++++++++++++++ .../TestAddonOverride/src/TestService.php | 19 ++ .../TestAddonOverride/src/TestService.php | 19 ++ 3 files changed, 300 insertions(+) create mode 100644 application/test/OmekaTest/Module/AutoloaderTest.php create mode 100644 application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php create mode 100644 application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php diff --git a/application/test/OmekaTest/Module/AutoloaderTest.php b/application/test/OmekaTest/Module/AutoloaderTest.php new file mode 100644 index 0000000000..3163e6c4b0 --- /dev/null +++ b/application/test/OmekaTest/Module/AutoloaderTest.php @@ -0,0 +1,262 @@ +testModuleName = 'TestAutoloader_' . uniqid(); + $this->localModulePath = OMEKA_PATH . '/modules/' . $this->testModuleName; + $this->addonModulePath = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + } + + protected function tearDown(): void + { + // Clean up test modules. + $this->removeDirectory($this->localModulePath); + $this->removeDirectory($this->addonModulePath); + + parent::tearDown(); + } + + protected function removeDirectory($path) + { + // Handle symlinks first. + if (is_link($path)) { + unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + rmdir($path); + } + + /** + * Create a test module with a service class. + */ + protected function createTestModule($basePath, $source) + { + mkdir($basePath, 0755, true); + mkdir($basePath . '/src', 0755, true); + + // Create Module.php. + $modulePhp = <<testModuleName}; + +use Omeka\Module\AbstractModule; + +class Module extends AbstractModule +{ + const SOURCE = '$source'; + + public function getConfig() + { + return []; + } +} +PHP; + file_put_contents($basePath . '/Module.php', $modulePhp); + + // Create a PSR-4 service class. + $servicePhp = <<testModuleName}; + +class TestService +{ + const SOURCE = '$source'; + + public function getSource(): string + { + return self::SOURCE; + } +} +PHP; + file_put_contents($basePath . '/src/TestService.php', $servicePhp); + + // Create config/module.ini. + mkdir($basePath . '/config', 0755, true); + $ini = "[info]\n"; + $ini .= "name = \"{$this->testModuleName}\"\n"; + $ini .= "version = \"1.0.0\"\n"; + file_put_contents($basePath . '/config/module.ini', $ini); + } + + /** + * Test that local module (modules/) takes precedence over addons/modules/. + * + * This is the key test for the bootstrap autoloader. When a module exists + * in both locations with different content, the autoloader MUST load + * the class from modules/ (local) to allow local overrides. + */ + public function testLocalModuleTakesPrecedenceOverAddons() + { + // Create module in both locations with different SOURCE values. + $this->createTestModule($this->addonModulePath, 'addon'); + $this->createTestModule($this->localModulePath, 'local'); + + $className = $this->testModuleName . '\\TestService'; + + if (class_exists($className, false)) { + $this->markTestSkipped('Class already loaded from previous test.'); + } + + $this->assertTrue(class_exists($className, true), 'Class should be autoloadable'); + + // The class MUST be loaded from modules/ (local), not addons/modules/. + $reflection = new \ReflectionClass($className); + $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(), + 'Class should be loaded from modules/, not addons/modules/'); + $this->assertStringNotContainsString('/addons/', $reflection->getFileName(), + 'Class should NOT be loaded from addons/modules/'); + $this->assertEquals('local', $className::SOURCE, + 'Class SOURCE should be "local" to confirm modules/ takes precedence'); + } + + /** + * Test that symlink from modules/ to addons/ does NOT trigger override. + * + * When modules/Foo is a symlink to addons/modules/Foo, the autoloader + * should NOT intervene (is_link check returns true), allowing Composer + * to handle the loading normally. + */ + public function testSymlinkModuleDoesNotTriggerOverride() + { + // Create module in addons/modules/. + $this->createTestModule($this->addonModulePath, 'addon'); + + // Create symlink from modules/ to addons/modules/. + symlink($this->addonModulePath, $this->localModulePath); + + $className = $this->testModuleName . '\\TestService'; + + if (class_exists($className, false)) { + $this->markTestSkipped('Class already loaded from previous test.'); + } + + // With symlink, is_link() returns true, so autoloader returns false. + // The class will only be loadable if Composer knows about it. + // Since our test module is not in Composer, it won't be loadable. + // This is expected behavior - the autoloader should NOT intervene for symlinks. + + // We can verify the autoloader's logic by checking the conditions directly. + $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + + $this->assertTrue(is_dir($localModule), 'Local module path should exist (symlink)'); + $this->assertTrue(is_link($localModule), 'Local module should be a symlink'); + $this->assertTrue(is_dir($addonModule), 'Addon module path should exist'); + + // The autoloader condition: !is_dir($local) || is_link($local) || !is_dir($addon) + // For symlink: is_link($local) is true, so autoloader returns false (no override). + $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule); + $this->assertFalse($autoloaderWouldIntervene, + 'Autoloader should NOT intervene when local is a symlink'); + } + + /** + * Test autoloader does not intervene when module only exists in modules/. + */ + public function testAutoloaderDoesNotInterveneForLocalOnlyModule() + { + // Create module only in modules/. + $this->createTestModule($this->localModulePath, 'local'); + + $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + + $this->assertTrue(is_dir($localModule), 'Local module should exist'); + $this->assertFalse(is_dir($addonModule), 'Addon module should NOT exist'); + + // Autoloader condition check. + $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule); + $this->assertFalse($autoloaderWouldIntervene, + 'Autoloader should NOT intervene when module only exists in modules/'); + } + + /** + * Test autoloader does not intervene when module only exists in addons/modules/. + */ + public function testAutoloaderDoesNotInterveneForAddonOnlyModule() + { + // Create module only in addons/modules/. + $this->createTestModule($this->addonModulePath, 'addon'); + + $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + + $this->assertFalse(is_dir($localModule), 'Local module should NOT exist'); + $this->assertTrue(is_dir($addonModule), 'Addon module should exist'); + + // Autoloader condition check. + $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule); + $this->assertFalse($autoloaderWouldIntervene, + 'Autoloader should NOT intervene when module only exists in addons/modules/'); + } + + /** + * Test the Common module special case for root-level classes. + * + * Common module has classes like TraitModule in the root directory (not src/). + * The autoloader has special handling for these. + */ + public function testCommonModuleRootLevelClasses() + { + // This test uses the real Common module if it exists in both locations. + $localCommon = OMEKA_PATH . '/modules/Common'; + $addonCommon = OMEKA_PATH . '/addons/modules/Common'; + + if (!is_dir($localCommon) || is_link($localCommon) || !is_dir($addonCommon)) { + $this->markTestSkipped('Common module not present in both locations.'); + } + + // Test that TraitModule can be loaded. + $this->assertTrue( + trait_exists('Common\\TraitModule', true), + 'Common\\TraitModule should be loadable' + ); + + // Verify it's loaded from local. + $reflection = new \ReflectionClass('Common\\TraitModule'); + $this->assertStringContainsString('/modules/Common/', $reflection->getFileName(), + 'Common\\TraitModule should be loaded from modules/Common/'); + $this->assertStringNotContainsString('/addons/', $reflection->getFileName(), + 'Common\\TraitModule should NOT be loaded from addons/'); + } +} diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php b/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php new file mode 100644 index 0000000000..23e6643c6c --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php @@ -0,0 +1,19 @@ + Date: Mon, 2 Feb 2026 00:00:00 +0000 Subject: [PATCH 18/26] Fixed notice when a theme as no form. --- application/src/Site/Theme/Theme.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/Site/Theme/Theme.php b/application/src/Site/Theme/Theme.php index 672784bbd6..68ef8b9996 100644 --- a/application/src/Site/Theme/Theme.php +++ b/application/src/Site/Theme/Theme.php @@ -165,7 +165,7 @@ public function getThumbnail($key = null) public function isConfigurable() { $configSpec = $this->getConfigSpec(); - return $configSpec && $configSpec['elements']; + return $configSpec && !empty($configSpec['elements']); } /** From cf2fb68ea8e50395ae8b0f5f42dacccce90d6dc8 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 19/26] Renamed addons directory as composer-addons. --- .gitignore | 8 +-- .php-cs-fixer.dist.php | 2 +- application/config/application.config.php | 2 +- .../composer-addon-installer/composer.json | 2 +- .../src/AddonInstaller.php | 6 +- .../src/AddonInstallerPlugin.php | 4 +- .../data/scripts/install-addon-deps.php | 8 +-- .../data/scripts/install-omeka-assets.php | 12 ++-- .../src/Service/ModuleManagerFactory.php | 8 +-- .../src/Service/ThemeManagerFactory.php | 8 +-- .../test/OmekaTest/Module/AutoloaderTest.php | 38 ++++++------- .../modules/TestAddonBasic/Module.php | 2 +- .../modules/TestAddonBasic/composer.json | 2 +- .../modules/TestAddonDependency/Module.php | 2 +- .../modules/TestAddonDependency/composer.json | 0 .../modules/TestAddonOverride/Module.php | 4 +- .../modules/TestAddonOverride/composer.json | 2 +- .../TestAddonOverride/src/TestService.php | 4 +- .../modules/TestAddonStandalone/Module.php | 0 .../modules/TestAddonStandalone/composer.json | 0 .../themes/test-override/composer.json | 2 +- .../themes/test-override/config/theme.ini | 0 .../test-override/view/layout/layout.phtml | 0 .../modules/TestAddonOverride/Module.php | 2 +- .../TestAddonOverride/src/TestService.php | 2 +- .../Service/ModuleManagerFactoryTest.php | 56 +++++++++---------- .../Service/ThemeManagerFactoryTest.php | 56 +++++++++---------- bootstrap.php | 6 +- {addons => composer-addons}/README.md | 2 +- {addons => composer-addons}/modules/.gitkeep | 0 {addons => composer-addons}/themes/.gitkeep | 0 31 files changed, 120 insertions(+), 120 deletions(-) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonBasic/Module.php (78%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonBasic/composer.json (95%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonDependency/Module.php (88%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonDependency/composer.json (100%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonOverride/Module.php (69%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonOverride/composer.json (93%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonOverride/src/TestService.php (67%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonStandalone/Module.php (100%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/modules/TestAddonStandalone/composer.json (100%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/themes/test-override/composer.json (91%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/themes/test-override/config/theme.ini (100%) rename application/test/OmekaTest/Module/fixtures/{addons => composer-addons}/themes/test-override/view/layout/layout.phtml (100%) rename {addons => composer-addons}/README.md (98%) rename {addons => composer-addons}/modules/.gitkeep (100%) rename {addons => composer-addons}/themes/.gitkeep (100%) diff --git a/.gitignore b/.gitignore index 922bafc779..1d18db58f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,10 @@ /files/ /modules/ /themes/ -/addons/modules/* -!/addons/modules/.gitkeep -/addons/themes/* -!/addons/themes/.gitkeep +/composer-addons/modules/* +!/composer-addons/modules/.gitkeep +/composer-addons/themes/* +!/composer-addons/themes/.gitkeep /application/test/config/database.ini /application/test/.phpunit.result.cache /application/asset/css/font-awesome/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 203041a7d8..e571ca5ea1 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -52,7 +52,7 @@ ->exclude('application/data/overrides') ->exclude('config') ->exclude('files') - ->exclude('addons') + ->exclude('composer-addons') ->exclude('modules') ->exclude('node_modules') ->exclude('themes') diff --git a/application/config/application.config.php b/application/config/application.config.php index 69444d8135..3f863cd936 100644 --- a/application/config/application.config.php +++ b/application/config/application.config.php @@ -32,7 +32,7 @@ 'module_paths' => [ 'Omeka' => OMEKA_PATH . '/application', OMEKA_PATH . '/modules', - OMEKA_PATH . '/addons/modules', + OMEKA_PATH . '/composer-addons/modules', ], 'config_glob_paths' => [ OMEKA_PATH . '/config/local.config.php', diff --git a/application/data/composer-addon-installer/composer.json b/application/data/composer-addon-installer/composer.json index bb03d5209f..89d5b81024 100644 --- a/application/data/composer-addon-installer/composer.json +++ b/application/data/composer-addon-installer/composer.json @@ -1,7 +1,7 @@ { "name": "omeka/composer-addon-installer", "version": "3.0", - "description": "Composer plugin to install Omeka S modules and themes into addons/modules/ and addons/themes/.", + "description": "Composer plugin to install Omeka S modules and themes into composer-addons/modules/ and composer-addons/themes/.", "type": "composer-plugin", "license": "GPL-3.0", "require": { diff --git a/application/data/composer-addon-installer/src/AddonInstaller.php b/application/data/composer-addon-installer/src/AddonInstaller.php index 779f1d4ebd..e4b3722dc5 100644 --- a/application/data/composer-addon-installer/src/AddonInstaller.php +++ b/application/data/composer-addon-installer/src/AddonInstaller.php @@ -7,7 +7,7 @@ /** * Composer installer for Omeka S modules and themes. * - * Installs packages to addons/modules/ or addons/themes/ based on type. + * Installs packages to composer-addons/modules/ or composer-addons/themes/ based on type. * Name transformations align with composer/installers OmekaSInstaller. * * Supports extra options: @@ -124,9 +124,9 @@ public function getInstallPath(PackageInterface $package): string $addonName = static::getInstallName($package); switch ($package->getType()) { case 'omeka-s-module': - return 'addons/modules/' . $addonName; + return 'composer-addons/modules/' . $addonName; case 'omeka-s-theme': - return 'addons/themes/' . $addonName; + return 'composer-addons/themes/' . $addonName; default: throw new \InvalidArgumentException('Invalid Omeka S add-on package type'); // @translate } diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index d458f6129d..5f75929acd 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -107,8 +107,8 @@ public function onPostPackageUpdate(PackageEvent $event) * files (TraitModule.php, etc.) via require_once with file paths like: * require_once dirname(__DIR__) . '/Common/TraitModule.php'; * - * When Common is installed via Composer to addons/modules/Common/, this - * path doesn't work. A symlink modules/Common -> addons/modules/Common + * When Common is installed via Composer to composer-addons/modules/Common/, this + * path doesn't work. A symlink modules/Common -> composer-addons/modules/Common * ensures backward compatibility. */ protected function handleCommonModuleSymlink($package) diff --git a/application/data/scripts/install-addon-deps.php b/application/data/scripts/install-addon-deps.php index 0d234bc87a..ada90d9914 100644 --- a/application/data/scripts/install-addon-deps.php +++ b/application/data/scripts/install-addon-deps.php @@ -7,8 +7,8 @@ * are not installed automatically. This script reads the add-on composer.json * and installs its dependencies via the root Omeka composer. * - * Note: Add-ons installed via `composer require` (in addons/modules/ or - * addons/themes/) have their dependencies installed automatically. + * Note: Add-ons installed via `composer require` (in composer-addons/modules/ or + * composer-addons/themes/) have their dependencies installed automatically. * * Usage: * php application/data/scripts/install-addon-deps.php ModuleName @@ -46,13 +46,13 @@ if ($isTheme) { $possiblePaths = [ dirname(__DIR__, 3) . '/themes/' . $addonName, - dirname(__DIR__, 3) . '/addons/themes/' . $addonName, + dirname(__DIR__, 3) . '/composer-addons/themes/' . $addonName, ]; $addonType = 'Theme'; } else { $possiblePaths = [ dirname(__DIR__, 3) . '/modules/' . $addonName, - dirname(__DIR__, 3) . '/addons/modules/' . $addonName, + dirname(__DIR__, 3) . '/composer-addons/modules/' . $addonName, ]; $addonType = 'Module'; } diff --git a/application/data/scripts/install-omeka-assets.php b/application/data/scripts/install-omeka-assets.php index a2cfb29e9f..9893080e18 100644 --- a/application/data/scripts/install-omeka-assets.php +++ b/application/data/scripts/install-omeka-assets.php @@ -7,8 +7,8 @@ * (js, css, etc.) defined in composer.json are not downloaded automatically. * This script reads the add-on composer.json and downloads the assets. * - * Note: Add-ons installed via `composer require` (in addons/modules/ or - * addons/themes/) have their assets downloaded automatically. + * Note: Add-ons installed via `composer require` (in composer-addons/modules/ or + * composer-addons/themes/) have their assets downloaded automatically. * * Usage: * php application/data/scripts/install-omeka-assets.php [module-or-theme-name] @@ -105,11 +105,11 @@ public function debug($message, $extra = []) // Scan all module and theme directories $moduleDirs = [ OMEKA_PATH . '/modules', - OMEKA_PATH . '/addons/modules', + OMEKA_PATH . '/composer-addons/modules', ]; $themeDirs = [ OMEKA_PATH . '/themes', - OMEKA_PATH . '/addons/themes', + OMEKA_PATH . '/composer-addons/themes', ]; foreach ($moduleDirs as $dir) { @@ -138,12 +138,12 @@ public function debug($message, $extra = []) if ($isTheme) { $possiblePaths = [ OMEKA_PATH . '/themes/' . $name, - OMEKA_PATH . '/addons/themes/' . $name, + OMEKA_PATH . '/composer-addons/themes/' . $name, ]; } else { $possiblePaths = [ OMEKA_PATH . '/modules/' . $name, - OMEKA_PATH . '/addons/modules/' . $name, + OMEKA_PATH . '/composer-addons/modules/' . $name, ]; } diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index 57ea4524e4..1829c7f54e 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -35,14 +35,14 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar // Get all modules from the filesystem. // Scan local modules first so they take precedence over add-ons. - // Note: addons/modules/ is scanned even though installed.json contains + // Note: composer-addons/modules/ is scanned even though installed.json contains // the module list. This ensures Module.php exists, handles out-of-sync // cases, and maintains consistency with how modules/ works. The // installed.json is only used for metadata (name, version, etc.), not // for discovering which modules are installed. $modulePaths = [ OMEKA_PATH . '/modules', - OMEKA_PATH . '/addons/modules', + OMEKA_PATH . '/composer-addons/modules', ]; $registered = []; foreach ($modulePaths as $modulePath) { @@ -65,10 +65,10 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $module = $manager->registerModule($moduleId); - // Only use installed.json for modules in addons/modules/. + // Only use installed.json for modules in composer-addons/modules/. // Local modules in modules/ must read their own files. $info = null; - $isComposerAddon = strpos($dir->getPathname(), '/addons/modules/') !== false; + $isComposerAddon = strpos($dir->getPathname(), '/composer-addons/modules/') !== false; if ($isComposerAddon) { // Try installed.json first to avoid checking compatibility. $info = $infoReader->getFromComposerInstalled($moduleId, 'module'); diff --git a/application/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 2face02d6c..1f0fb759f5 100644 --- a/application/src/Service/ThemeManagerFactory.php +++ b/application/src/Service/ThemeManagerFactory.php @@ -28,14 +28,14 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar // Get all themes from the filesystem. // Scan local themes first so they take precedence over add-ons. - // Note: addons/themes/ is scanned even though installed.json contains + // Note: composer-addons/themes/ is scanned even though installed.json contains // the theme list. This ensures theme files exist, handles out-of-sync // cases, and maintains consistency with how themes/ works. The // installed.json is only used for metadata (name, version, etc.), not // for discovering which themes are installed. $themePaths = [ 'themes' => OMEKA_PATH . '/themes', - 'addons/themes' => OMEKA_PATH . '/addons/themes', + 'composer-addons/themes' => OMEKA_PATH . '/composer-addons/themes', ]; $registered = []; foreach ($themePaths as $basePath => $themePath) { @@ -59,10 +59,10 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar $theme = $manager->registerTheme($themeId); $theme->setBasePath($basePath); - // Only use installed.json for themes in addons/themes/. + // Only use installed.json for themes in composer-addons/themes/. // Local themes in themes/ must read their own files. $info = null; - $isComposerAddon = strpos($dir->getPathname(), '/addons/themes/') !== false; + $isComposerAddon = strpos($dir->getPathname(), '/composer-addons/themes/') !== false; if ($isComposerAddon) { // Try installed.json first to avoid checking compatibility. $info = $infoReader->getFromComposerInstalled($themeId, 'theme'); diff --git a/application/test/OmekaTest/Module/AutoloaderTest.php b/application/test/OmekaTest/Module/AutoloaderTest.php index 3163e6c4b0..0d45005150 100644 --- a/application/test/OmekaTest/Module/AutoloaderTest.php +++ b/application/test/OmekaTest/Module/AutoloaderTest.php @@ -5,10 +5,10 @@ use Omeka\Test\TestCase; /** - * Test the bootstrap autoloader that prioritizes modules/ over addons/modules/. + * Test the bootstrap autoloader that prioritizes modules/ over composer-addons/modules/. * * The autoloader in bootstrap.php ensures that when a module exists in both - * modules/ (local) and addons/modules/ (composer), classes are loaded from + * modules/ (local) and composer-addons/modules/ (composer), classes are loaded from * modules/ to allow local overrides of composer-installed modules. * * IMPORTANT: The autoloader ONLY intervenes when a module exists in BOTH @@ -28,7 +28,7 @@ protected function setUp(): void // Generate unique module name to avoid conflicts. $this->testModuleName = 'TestAutoloader_' . uniqid(); $this->localModulePath = OMEKA_PATH . '/modules/' . $this->testModuleName; - $this->addonModulePath = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + $this->addonModulePath = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName; } protected function tearDown(): void @@ -119,7 +119,7 @@ public function getSource(): string } /** - * Test that local module (modules/) takes precedence over addons/modules/. + * Test that local module (modules/) takes precedence over composer-addons/modules/. * * This is the key test for the bootstrap autoloader. When a module exists * in both locations with different content, the autoloader MUST load @@ -139,12 +139,12 @@ public function testLocalModuleTakesPrecedenceOverAddons() $this->assertTrue(class_exists($className, true), 'Class should be autoloadable'); - // The class MUST be loaded from modules/ (local), not addons/modules/. + // The class MUST be loaded from modules/ (local), not composer-addons/modules/. $reflection = new \ReflectionClass($className); $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(), - 'Class should be loaded from modules/, not addons/modules/'); - $this->assertStringNotContainsString('/addons/', $reflection->getFileName(), - 'Class should NOT be loaded from addons/modules/'); + 'Class should be loaded from modules/, not composer-addons/modules/'); + $this->assertStringNotContainsString('/composer-addons/', $reflection->getFileName(), + 'Class should NOT be loaded from composer-addons/modules/'); $this->assertEquals('local', $className::SOURCE, 'Class SOURCE should be "local" to confirm modules/ takes precedence'); } @@ -152,16 +152,16 @@ public function testLocalModuleTakesPrecedenceOverAddons() /** * Test that symlink from modules/ to addons/ does NOT trigger override. * - * When modules/Foo is a symlink to addons/modules/Foo, the autoloader + * When modules/Foo is a symlink to composer-addons/modules/Foo, the autoloader * should NOT intervene (is_link check returns true), allowing Composer * to handle the loading normally. */ public function testSymlinkModuleDoesNotTriggerOverride() { - // Create module in addons/modules/. + // Create module in composer-addons/modules/. $this->createTestModule($this->addonModulePath, 'addon'); - // Create symlink from modules/ to addons/modules/. + // Create symlink from modules/ to composer-addons/modules/. symlink($this->addonModulePath, $this->localModulePath); $className = $this->testModuleName . '\\TestService'; @@ -177,7 +177,7 @@ public function testSymlinkModuleDoesNotTriggerOverride() // We can verify the autoloader's logic by checking the conditions directly. $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; - $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName; $this->assertTrue(is_dir($localModule), 'Local module path should exist (symlink)'); $this->assertTrue(is_link($localModule), 'Local module should be a symlink'); @@ -199,7 +199,7 @@ public function testAutoloaderDoesNotInterveneForLocalOnlyModule() $this->createTestModule($this->localModulePath, 'local'); $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; - $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName; $this->assertTrue(is_dir($localModule), 'Local module should exist'); $this->assertFalse(is_dir($addonModule), 'Addon module should NOT exist'); @@ -211,15 +211,15 @@ public function testAutoloaderDoesNotInterveneForLocalOnlyModule() } /** - * Test autoloader does not intervene when module only exists in addons/modules/. + * Test autoloader does not intervene when module only exists in composer-addons/modules/. */ public function testAutoloaderDoesNotInterveneForAddonOnlyModule() { - // Create module only in addons/modules/. + // Create module only in composer-addons/modules/. $this->createTestModule($this->addonModulePath, 'addon'); $localModule = OMEKA_PATH . '/modules/' . $this->testModuleName; - $addonModule = OMEKA_PATH . '/addons/modules/' . $this->testModuleName; + $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $this->testModuleName; $this->assertFalse(is_dir($localModule), 'Local module should NOT exist'); $this->assertTrue(is_dir($addonModule), 'Addon module should exist'); @@ -227,7 +227,7 @@ public function testAutoloaderDoesNotInterveneForAddonOnlyModule() // Autoloader condition check. $autoloaderWouldIntervene = is_dir($localModule) && !is_link($localModule) && is_dir($addonModule); $this->assertFalse($autoloaderWouldIntervene, - 'Autoloader should NOT intervene when module only exists in addons/modules/'); + 'Autoloader should NOT intervene when module only exists in composer-addons/modules/'); } /** @@ -240,7 +240,7 @@ public function testCommonModuleRootLevelClasses() { // This test uses the real Common module if it exists in both locations. $localCommon = OMEKA_PATH . '/modules/Common'; - $addonCommon = OMEKA_PATH . '/addons/modules/Common'; + $addonCommon = OMEKA_PATH . '/composer-addons/modules/Common'; if (!is_dir($localCommon) || is_link($localCommon) || !is_dir($addonCommon)) { $this->markTestSkipped('Common module not present in both locations.'); @@ -256,7 +256,7 @@ trait_exists('Common\\TraitModule', true), $reflection = new \ReflectionClass('Common\\TraitModule'); $this->assertStringContainsString('/modules/Common/', $reflection->getFileName(), 'Common\\TraitModule should be loaded from modules/Common/'); - $this->assertStringNotContainsString('/addons/', $reflection->getFileName(), + $this->assertStringNotContainsString('/composer-addons/', $reflection->getFileName(), 'Common\\TraitModule should NOT be loaded from addons/'); } } diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php similarity index 78% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php index 78ae7a6c9c..18cc7ba140 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/Module.php +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php @@ -5,7 +5,7 @@ use Omeka\Module\AbstractModule; /** - * Test basic addon module in addons/modules/. + * Test basic addon module in composer-addons/modules/. */ class Module extends AbstractModule { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json similarity index 95% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/composer.json rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json index f29bdda6b3..5670ecc783 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonBasic/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json @@ -1,7 +1,7 @@ { "name": "test/omeka-s-module-test-addon-basic", "type": "omeka-s-module", - "description": "Basic test module installed via composer in addons/modules/", + "description": "Basic test module installed via composer in composer-addons/modules/", "license": "GPL-3.0-or-later", "authors": [ { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/Module.php similarity index 88% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/Module.php rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/Module.php index 91a6750b09..11018a234f 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/Module.php +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/Module.php @@ -9,7 +9,7 @@ * Test addon module with a dependency on laminas/laminas-json. * * This module verifies that composer dependencies are properly handled - * when a module is installed via composer in addons/modules/. + * when a module is installed via composer in composer-addons/modules/. */ class Module extends AbstractModule { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json similarity index 100% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonDependency/composer.json rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/Module.php similarity index 69% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/Module.php rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/Module.php index e4a22832af..c63ab3d8f3 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/Module.php +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/Module.php @@ -5,10 +5,10 @@ use Omeka\Module\AbstractModule; /** - * Test addon module in addons/modules/ to verify override priority. + * Test addon module in composer-addons/modules/ to verify override priority. * * When a module with the same name exists in modules/, it should take - * precedence over this version in addons/modules/. + * precedence over this version in composer-addons/modules/. */ class Module extends AbstractModule { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json similarity index 93% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/composer.json rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json index f2e7ded1af..dd4a573ebf 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json @@ -1,7 +1,7 @@ { "name": "test/omeka-s-module-test-addon-override", "type": "omeka-s-module", - "description": "Test module to verify modules/ takes precedence over addons/modules/", + "description": "Test module to verify modules/ takes precedence over composer-addons/modules/", "license": "GPL-3.0-or-later", "authors": [ { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/src/TestService.php similarity index 67% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/src/TestService.php index 23e6643c6c..7788ed12f6 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonOverride/src/TestService.php +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/src/TestService.php @@ -3,10 +3,10 @@ namespace TestAddonOverride; /** - * Test service class in addons/modules/ (composer version). + * Test service class in composer-addons/modules/ (composer version). * * This class should NOT be loaded when a local version exists in modules/, - * because modules/ takes precedence over addons/modules/. + * because modules/ takes precedence over composer-addons/modules/. */ class TestService { diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php similarity index 100% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/Module.php rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php diff --git a/application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/composer.json similarity index 100% rename from application/test/OmekaTest/Module/fixtures/addons/modules/TestAddonStandalone/composer.json rename to application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/composer.json diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json similarity index 91% rename from application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json rename to application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json index 59ec7f6fca..6f0b089e06 100644 --- a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json @@ -1,7 +1,7 @@ { "name": "test/omeka-s-theme-test-override", "type": "omeka-s-theme", - "description": "Theme for Omeka S: Test theme to verify themes/ takes precedence over addons/themes/", + "description": "Theme for Omeka S: Test theme to verify themes/ takes precedence over composer-addons/themes/", "license": "GPL-3.0-or-later", "authors": [ { diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/config/theme.ini similarity index 100% rename from application/test/OmekaTest/Module/fixtures/addons/themes/test-override/config/theme.ini rename to application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/config/theme.ini diff --git a/application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml similarity index 100% rename from application/test/OmekaTest/Module/fixtures/addons/themes/test-override/view/layout/layout.phtml rename to application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml diff --git a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php index d617960191..2a2842d6f7 100644 --- a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php +++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php @@ -8,7 +8,7 @@ * Test module in modules/ that should override the composer version. * * This local version should take precedence over the version in - * addons/modules/TestAddonOverride/. + * composer-addons/modules/TestAddonOverride/. */ class Module extends AbstractModule { diff --git a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php index 1d7b6f72a4..79b141d4a2 100644 --- a/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php +++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php @@ -5,7 +5,7 @@ /** * Test service class in modules/ (local version). * - * This class should be loaded instead of the one in addons/modules/ + * This class should be loaded instead of the one in composer-addons/modules/ * when both exist, because modules/ takes precedence. */ class TestService diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php index cdb734ef31..4532e15730 100644 --- a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -7,10 +7,10 @@ use Omeka\Test\TestCase; /** - * Test ModuleManagerFactory with addons directory support. + * Test ModuleManagerFactory with composer-addons directory support. * * These tests verify that: - * - modules/ (local/manual) takes precedence over addons/modules/ (composer) + * - modules/ (local/manual) takes precedence over composer-addons/modules/ (composer) * - Both directories are properly scanned for modules * - Various combinations of module.ini and composer.json are handled * - The directory structure exists @@ -176,14 +176,14 @@ public function testModulePathsIncludesAddonsDirectory() $modulePaths = $config['module_listener_options']['module_paths']; $this->assertContains(OMEKA_PATH . '/modules', $modulePaths); - $this->assertContains(OMEKA_PATH . '/addons/modules', $modulePaths); + $this->assertContains(OMEKA_PATH . '/composer-addons/modules', $modulePaths); } /** - * Test that modules/ comes before addons/modules/ (for priority). + * Test that modules/ comes before composer-addons/modules/ (for priority). * * Local modules in modules/ should take precedence over - * composer-installed modules in addons/modules/. + * composer-installed modules in composer-addons/modules/. */ public function testModulesDirectoryHasPriorityOverAddons() { @@ -191,27 +191,27 @@ public function testModulesDirectoryHasPriorityOverAddons() $modulePaths = $config['module_listener_options']['module_paths']; $modulesIndex = array_search(OMEKA_PATH . '/modules', $modulePaths); - $addonsIndex = array_search(OMEKA_PATH . '/addons/modules', $modulePaths); + $addonsIndex = array_search(OMEKA_PATH . '/composer-addons/modules', $modulePaths); $this->assertNotFalse($modulesIndex, 'modules/ should be in module_paths'); - $this->assertNotFalse($addonsIndex, 'addons/modules/ should be in module_paths'); - $this->assertLessThan($addonsIndex, $modulesIndex, 'modules/ should come before addons/modules/'); + $this->assertNotFalse($addonsIndex, 'composer-addons/modules/ should be in module_paths'); + $this->assertLessThan($addonsIndex, $modulesIndex, 'modules/ should come before composer-addons/modules/'); } /** - * Test that addons/modules directory exists. + * Test that composer-addons/modules directory exists. */ public function testAddonsModulesDirectoryExists() { - $this->assertDirectoryExists(OMEKA_PATH . '/addons/modules'); + $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/modules'); } /** - * Test that addons/themes directory exists. + * Test that composer-addons/themes directory exists. */ public function testAddonsThemesDirectoryExists() { - $this->assertDirectoryExists(OMEKA_PATH . '/addons/themes'); + $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/themes'); } /** @@ -318,11 +318,11 @@ public function testInfoReaderWithNoSources() } // ------------------------------------------------------------------------- - // Tests: Priority between modules/ and addons/modules/ + // Tests: Priority between modules/ and composer-addons/modules/ // ------------------------------------------------------------------------- /** - * Test that a module in modules/ takes precedence over same module in addons/modules/. + * Test that a module in modules/ takes precedence over same module in composer-addons/modules/. * * This test creates real modules in OMEKA_PATH to verify factory behavior. */ @@ -330,13 +330,13 @@ public function testLocalModuleTakesPrecedenceOverAddons() { $moduleName = 'TestPriority_' . uniqid(); $localModulePath = OMEKA_PATH . '/modules/' . $moduleName; - $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; + $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName; $this->createdModules[] = $localModulePath; $this->createdModules[] = $addonsModulePath; try { - // Create module in addons/modules first. + // Create module in composer-addons/modules first. $this->createModuleWithIni($addonsModulePath . '/..', $moduleName, '1.0.0', ['description' => 'Addons version']); // Create module in modules/ (should take precedence). @@ -360,18 +360,18 @@ public function testLocalModuleTakesPrecedenceOverAddons() } /** - * Test module in addons/modules/ only (no local override). + * Test module in composer-addons/modules/ only (no local override). */ public function testModuleInAddonsOnlyIsRecognized() { $moduleName = 'TestAddonsOnly_' . uniqid(); - $addonsModulePath = OMEKA_PATH . '/addons/modules/' . $moduleName; + $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName; $this->createdModules[] = $addonsModulePath; try { $this->createModuleWithComposer( - OMEKA_PATH . '/addons/modules', + OMEKA_PATH . '/composer-addons/modules', $moduleName, '1.5.0' ); @@ -569,7 +569,7 @@ public function testInstallerNameDerivedFromProjectName() * Test that local module info is used even when module exists in installed.json. * * This is a critical test for the override feature: when a module exists in - * both modules/ (local) and addons/modules/ (composer), AND there's an entry + * both modules/ (local) and composer-addons/modules/ (composer), AND there's an entry * in installed.json, the LOCAL module's info (from module.ini or composer.json) * must be used, NOT the installed.json entry. * @@ -582,7 +582,7 @@ public function testLocalModuleInfoTakesPrecedenceOverInstalledJson() $fixturesPath = __DIR__ . '/../Module/fixtures'; $moduleName = 'TestAddonOverride'; $localModulePath = $fixturesPath . '/modules/' . $moduleName; - $addonsModulePath = $fixturesPath . '/addons/modules/' . $moduleName; + $addonsModulePath = $fixturesPath . '/composer-addons/modules/' . $moduleName; // Verify fixtures exist. $this->assertDirectoryExists($localModulePath, 'Local module fixture should exist'); @@ -601,20 +601,20 @@ public function testLocalModuleInfoTakesPrecedenceOverInstalledJson() $this->assertStringContainsString('Composer', $addonsInfo['name']); // Test the path-based decision logic used by ModuleManagerFactory. - // For a module in modules/, strpos should NOT find '/addons/modules/'. + // For a module in modules/, strpos should NOT find '/composer-addons/modules/'. $this->assertFalse( - strpos($localModulePath, '/addons/modules/') !== false, - 'Local module path should not contain /addons/modules/' + strpos($localModulePath, '/composer-addons/modules/') !== false, + 'Local module path should not contain /composer-addons/modules/' ); - // For a module in addons/modules/, strpos SHOULD find '/addons/modules/'. + // For a module in composer-addons/modules/, strpos SHOULD find '/composer-addons/modules/'. $this->assertTrue( - strpos($addonsModulePath, '/addons/modules/') !== false, - 'Addons module path should contain /addons/modules/' + strpos($addonsModulePath, '/composer-addons/modules/') !== false, + 'Addons module path should contain /composer-addons/modules/' ); // Simulate the factory logic: for local modules, DON'T use installed.json. - $isComposerAddon = strpos($localModulePath, '/addons/modules/') !== false; + $isComposerAddon = strpos($localModulePath, '/composer-addons/modules/') !== false; $this->assertFalse($isComposerAddon, 'Local module should not be treated as composer addon'); // The correct info should come from read() of the local path. diff --git a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php index 7267823b75..24f11b4f1a 100644 --- a/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php @@ -7,10 +7,10 @@ use Omeka\Test\TestCase; /** - * Test ThemeManagerFactory with addons directory support. + * Test ThemeManagerFactory with composer-addons directory support. * * These tests verify that: - * - themes/ (local/manual) takes precedence over addons/themes/ (composer) + * - themes/ (local/manual) takes precedence over composer-addons/themes/ (composer) * - Both directories are properly scanned for themes * - Various combinations of theme.ini and composer.json are handled * - The directory structure exists @@ -151,11 +151,11 @@ protected function createTestTheme($basePath, $themeName, $version = '1.0.0') } /** - * Test that addons/themes directory exists. + * Test that composer-addons/themes directory exists. */ public function testAddonsThemesDirectoryExists() { - $this->assertDirectoryExists(OMEKA_PATH . '/addons/themes'); + $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/themes'); } /** @@ -167,12 +167,12 @@ public function testThemesDirectoryExists() } /** - * Test that a theme from addons/themes has correct basePath. + * Test that a theme from composer-addons/themes has correct basePath. */ public function testThemeFromAddonsDirectoryHasCorrectBasePath() { // Create a theme in the real addons directory for testing. - $addonsThemePath = OMEKA_PATH . '/addons/themes/TestThemeAddons_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/TestThemeAddons_' . uniqid(); $themeName = basename($addonsThemePath); try { @@ -199,9 +199,9 @@ public function testThemeFromAddonsDirectoryHasCorrectBasePath() // Verify theme was registered with correct basePath. $theme = $manager->getTheme($themeName); $this->assertNotNull($theme); - $this->assertEquals('addons/themes', $theme->getBasePath()); - $this->assertStringContainsString('/addons/themes/' . $themeName, $theme->getPath()); - $this->assertEquals('/addons/themes/' . $themeName . '/theme.jpg', $theme->getThumbnail()); + $this->assertEquals('composer-addons/themes', $theme->getBasePath()); + $this->assertStringContainsString('/composer-addons/themes/' . $themeName, $theme->getPath()); + $this->assertEquals('/composer-addons/themes/' . $themeName . '/theme.jpg', $theme->getThumbnail()); } finally { // Clean up. $this->removeDirectory($addonsThemePath); @@ -246,20 +246,20 @@ public function testThemeFromStandardDirectoryHasCorrectBasePath() $this->assertNotNull($theme); $this->assertEquals('themes', $theme->getBasePath()); $this->assertStringContainsString('/themes/' . $foundTheme, $theme->getPath()); - $this->assertStringNotContainsString('/addons/themes/', $theme->getPath()); + $this->assertStringNotContainsString('/composer-addons/themes/', $theme->getPath()); } /** - * Test that local theme (themes/) takes precedence over addons/themes/. + * Test that local theme (themes/) takes precedence over composer-addons/themes/. * * When the same theme exists in both directories, the one in themes/ * should be loaded (local override of composer-installed theme). */ public function testLocalThemeTakesPrecedenceOverAddons() { - // Create a theme in addons/themes. + // Create a theme in composer-addons/themes. $themeName = 'TestPrecedence_' . uniqid(); - $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; $localThemePath = OMEKA_PATH . '/themes/' . $themeName; try { @@ -296,7 +296,7 @@ public function testLocalThemeTakesPrecedenceOverAddons() $this->assertEquals('themes', $theme->getBasePath()); $this->assertStringContainsString('Local Override', $theme->getName()); $this->assertStringContainsString('/themes/' . $themeName, $theme->getPath()); - $this->assertStringNotContainsString('/addons/', $theme->getPath()); + $this->assertStringNotContainsString('/composer-addons/', $theme->getPath()); } finally { // Clean up. $this->removeDirectory($addonsThemePath); @@ -456,16 +456,16 @@ public function testInfoReaderFallsBackToIniWhenComposerInvalid() } // ------------------------------------------------------------------------- - // Tests: Theme with composer.json in addons/themes/ + // Tests: Theme with composer.json in composer-addons/themes/ // ------------------------------------------------------------------------- /** - * Test theme in addons/themes/ with composer.json only (no theme.ini). + * Test theme in composer-addons/themes/ with composer.json only (no theme.ini). */ public function testThemeInAddonsWithComposerJsonOnly() { $themeName = 'TestAddonsComposer_' . uniqid(); - $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; $this->createdThemes[] = $addonsThemePath; @@ -497,7 +497,7 @@ public function testThemeInAddonsWithComposerJsonOnly() $theme = $manager->getTheme($themeName); $this->assertNotNull($theme, 'Theme with composer.json only should be recognized'); - $this->assertEquals('addons/themes', $theme->getBasePath()); + $this->assertEquals('composer-addons/themes', $theme->getBasePath()); $this->assertEquals('Composer Theme', $theme->getName()); $this->assertEquals('1.5.0', $theme->getIni('version')); $this->assertEquals('A composer-only theme', $theme->getIni('description')); @@ -507,12 +507,12 @@ public function testThemeInAddonsWithComposerJsonOnly() } /** - * Test theme in addons/themes/ with both theme.ini and composer.json. + * Test theme in composer-addons/themes/ with both theme.ini and composer.json. */ public function testThemeInAddonsWithBothSources() { $themeName = 'TestAddonsBoth_' . uniqid(); - $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; $this->createdThemes[] = $addonsThemePath; @@ -564,11 +564,11 @@ public function testThemeInAddonsWithBothSources() } // ------------------------------------------------------------------------- - // Tests: Priority between themes/ and addons/themes/ + // Tests: Priority between themes/ and composer-addons/themes/ // ------------------------------------------------------------------------- /** - * Test that a theme in themes/ takes precedence over same theme in addons/themes/. + * Test that a theme in themes/ takes precedence over same theme in composer-addons/themes/. * * This test creates real themes in OMEKA_PATH to verify factory behavior. */ @@ -576,13 +576,13 @@ public function testLocalThemeTakesPrecedenceWithBothUsingComposer() { $themeName = 'TestPriorityComposer_' . uniqid(); $localThemePath = OMEKA_PATH . '/themes/' . $themeName; - $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; $this->createdThemes[] = $localThemePath; $this->createdThemes[] = $addonsThemePath; try { - // Create theme in addons/themes with composer.json. + // Create theme in composer-addons/themes with composer.json. mkdir($addonsThemePath, 0755, true); $addonsComposer = [ 'name' => 'test/' . strtolower($themeName), @@ -627,18 +627,18 @@ public function testLocalThemeTakesPrecedenceWithBothUsingComposer() } /** - * Test theme in addons/themes/ only (no local override). + * Test theme in composer-addons/themes/ only (no local override). */ public function testThemeInAddonsOnlyIsRecognized() { $themeName = 'TestAddonsOnly_' . uniqid(); - $addonsThemePath = OMEKA_PATH . '/addons/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; $this->createdThemes[] = $addonsThemePath; try { $this->createThemeWithComposer( - OMEKA_PATH . '/addons/themes', + OMEKA_PATH . '/composer-addons/themes', $themeName, '1.5.0' ); @@ -656,7 +656,7 @@ public function testThemeInAddonsOnlyIsRecognized() $theme = $manager->getTheme($themeName); $this->assertNotNull($theme); - $this->assertEquals('addons/themes', $theme->getBasePath()); + $this->assertEquals('composer-addons/themes', $theme->getBasePath()); $this->assertEquals($themeName, $theme->getName()); $this->assertEquals('1.5.0', $theme->getIni('version')); } finally { diff --git a/bootstrap.php b/bootstrap.php index 4f152476c3..bf37dfea60 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -6,10 +6,10 @@ require 'vendor/autoload.php'; /* - * Autoloader to prioritize local modules/ over Composer's addons/modules/. + * Autoloader to prioritize local modules/ over Composer's composer-addons/modules/. * * When a module exists in both locations, this ensures classes are loaded - * from modules/ (local) instead of addons/modules/ (Composer). + * from modules/ (local) instead of composer-addons/modules/ (Composer). * * Registered AFTER Composer with prepend=true to run BEFORE Composer. */ @@ -23,7 +23,7 @@ // Check for conflict: module exists in both locations $localModule = OMEKA_PATH . '/modules/' . $moduleNamespace; - $addonModule = OMEKA_PATH . '/addons/modules/' . $moduleNamespace; + $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $moduleNamespace; // Only intervene if local exists as real dir (not symlink) AND addon exists if (!is_dir($localModule) || is_link($localModule) || !is_dir($addonModule)) { diff --git a/addons/README.md b/composer-addons/README.md similarity index 98% rename from addons/README.md rename to composer-addons/README.md index 4eb23e521c..245b64e7b8 100644 --- a/addons/README.md +++ b/composer-addons/README.md @@ -7,7 +7,7 @@ Add-ons are modules and themes managed by composer when they have the type The use of add-ons allows to use a single command `composer require xxx/yyy` to manage an Omeka instance, to avoid duplication of dependencies, and to improve speed of Omeka init. With composer, add-ons are installed automatically -under `addons/modules/` and `addons/themes/` and their own dependencies are +under `composer-addons/modules/` and `composer-addons/themes/` and their own dependencies are shared with the Omeka ones in `vendor/`. Omeka S still supports classic locations `modules/` and `themes/`. When a module diff --git a/addons/modules/.gitkeep b/composer-addons/modules/.gitkeep similarity index 100% rename from addons/modules/.gitkeep rename to composer-addons/modules/.gitkeep diff --git a/addons/themes/.gitkeep b/composer-addons/themes/.gitkeep similarity index 100% rename from addons/themes/.gitkeep rename to composer-addons/themes/.gitkeep From 9288683d1c831d435a7e97685e45a80fe9ef5b02 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 20/26] Removed the feature to manage an add-on as standalone. --- .../src/AddonInstaller.php | 13 ---- .../src/AddonInstallerPlugin.php | 62 +------------------ application/src/Module/InfoReader.php | 3 +- .../OmekaTest/Composer/AddonInstallerTest.php | 11 ---- .../test/OmekaTest/Module/InfoReaderTest.php | 1 - .../modules/TestAddonStandalone/Module.php | 19 ------ .../modules/TestAddonStandalone/composer.json | 21 ------- composer-addons/README.md | 1 - 8 files changed, 2 insertions(+), 129 deletions(-) delete mode 100644 application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php delete mode 100644 application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/composer.json diff --git a/application/data/composer-addon-installer/src/AddonInstaller.php b/application/data/composer-addon-installer/src/AddonInstaller.php index e4b3722dc5..de8e9da834 100644 --- a/application/data/composer-addon-installer/src/AddonInstaller.php +++ b/application/data/composer-addon-installer/src/AddonInstaller.php @@ -12,7 +12,6 @@ * * Supports extra options: * - installer-name: Explicit install name (overrides auto-detection) - * - standalone: If true, module keeps its own vendor/ directory */ class AddonInstaller extends LibraryInstaller { @@ -107,18 +106,6 @@ protected static function inflectThemeName($name): string return $name; } - /** - * Check if package wants standalone installation (own vendor/). - * - * @param PackageInterface $package - * @return bool - */ - public static function isStandalone(PackageInterface $package): bool - { - $extra = $package->getExtra(); - return !empty($extra['standalone']); - } - public function getInstallPath(PackageInterface $package): string { $addonName = static::getInstallName($package); diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index 5f75929acd..ee3a1263a2 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -8,13 +8,11 @@ use Composer\Installer\PackageEvents; use Composer\IO\IOInterface; use Composer\Plugin\PluginInterface; -use Composer\Util\ProcessExecutor; /** * Composer plugin for Omeka S add-on installation. * - * Registers the AddonInstaller and handles standalone modules that need their - * own vendor/ directory. + * Registers the AddonInstaller and handles Common module symlink. */ class AddonInstallerPlugin implements PluginInterface, EventSubscriberInterface { @@ -86,7 +84,6 @@ protected function removeCommonModuleSymlink($package) public function onPostPackageInstall(PackageEvent $event) { $package = $event->getOperation()->getPackage(); - $this->handleStandalonePackage($package); $this->handleCommonModuleSymlink($package); } @@ -96,7 +93,6 @@ public function onPostPackageInstall(PackageEvent $event) public function onPostPackageUpdate(PackageEvent $event) { $package = $event->getOperation()->getTargetPackage(); - $this->handleStandalonePackage($package); $this->handleCommonModuleSymlink($package); } @@ -152,60 +148,4 @@ protected function handleCommonModuleSymlink($package) )); } } - - /** - * If package is standalone, run composer install in its directory. - * - * Standalone packages have extra.standalone = true in their composer.json. - * This allows them to maintain their own vendor/ directory with specific - * dependency versions, isolated from the root project. - */ - protected function handleStandalonePackage($package) - { - if (!AddonInstaller::isStandalone($package)) { - return; - } - - $type = $package->getType(); - if (!in_array($type, ['omeka-s-module', 'omeka-s-theme'])) { - return; - } - - $installPath = $this->composer->getInstallationManager()->getInstallPath($package); - $composerJson = $installPath . '/composer.json'; - - if (!file_exists($composerJson)) { - $this->io->writeError(sprintf( - 'Standalone package %s has no composer.json, skipping vendor install', - $package->getPrettyName() - )); - return; - } - - $this->io->write(sprintf( - 'Installing standalone dependencies for %s...', - $package->getPrettyName() - )); - - $process = new ProcessExecutor($this->io); - $command = sprintf( - 'cd %s && composer install --no-dev --no-interaction --quiet 2>&1', - escapeshellarg($installPath) - ); - - $exitCode = $process->execute($command, $output); - - if ($exitCode !== 0) { - $this->io->writeError(sprintf( - 'Failed to install standalone dependencies for %s: %s', - $package->getPrettyName(), - $output - )); - } else { - $this->io->write(sprintf( - 'Standalone dependencies installed for %s', - $package->getPrettyName() - )); - } - } } diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index e0934cb342..1af5443fe2 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -14,7 +14,7 @@ * * Note: installed.json is used instead of installed.php because installed.php * only contains minimal data (version, type, install_path) without the `extra` - * keys needed for add-on metadata (label, standalone, etc.). + * keys needed for add-on metadata (label, configurable, etc.). */ class InfoReader { @@ -37,7 +37,6 @@ class InfoReader // theme_link is managed below. 'homepage' => 'module_link', 'version' => 'version', - // The mapping of key "standalone" is useless: it does not exist in ini. ]; /** diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php index 33326d9453..b60a1d47df 100644 --- a/application/test/OmekaTest/Composer/AddonInstallerTest.php +++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php @@ -50,17 +50,6 @@ public function testInstallerNameOverride(): void $this->assertEquals('CustomName', $result); } - public function testStandaloneFlag(): void - { - $package = $this->createMockPackage('vendor/module', 'omeka-s-module', [ - 'standalone' => true, - ]); - $this->assertTrue(AddonInstaller::isStandalone($package)); - - $package2 = $this->createMockPackage('vendor/module', 'omeka-s-module', []); - $this->assertFalse(AddonInstaller::isStandalone($package2)); - } - public function moduleNameProvider(): array { return [ diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php index 35d366f5e3..77b0bef895 100644 --- a/application/test/OmekaTest/Module/InfoReaderTest.php +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -12,7 +12,6 @@ * - composer.json only * - module.ini/theme.ini only * - Both sources (composer.json takes precedence) - * - standalone flag * - Version extraction */ class InfoReaderTest extends TestCase diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php deleted file mode 100644 index af385ebf7b..0000000000 --- a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonStandalone/Module.php +++ /dev/null @@ -1,19 +0,0 @@ - Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 21/26] Moved internal composer plugin omeka-assets into generic external-assets. --- application/data/omeka-assets/composer.json | 22 - .../omeka-assets/src/OmekaAssetsPlugin.php | 245 -- .../data/scripts/install-omeka-assets.php | 233 -- .../src/Module/OmekaAssetsInstaller.php | 343 --- .../OmekaTest/Composer/OmekaAssetsTest.php | 193 -- .../Module/OmekaAssetsInstallerTest.php | 253 -- composer-addons/README.md | 76 +- composer.json | 11 +- composer.lock | 2562 ++++++----------- 9 files changed, 888 insertions(+), 3050 deletions(-) delete mode 100644 application/data/omeka-assets/composer.json delete mode 100644 application/data/omeka-assets/src/OmekaAssetsPlugin.php delete mode 100644 application/data/scripts/install-omeka-assets.php delete mode 100644 application/src/Module/OmekaAssetsInstaller.php delete mode 100644 application/test/OmekaTest/Composer/OmekaAssetsTest.php delete mode 100644 application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php diff --git a/application/data/omeka-assets/composer.json b/application/data/omeka-assets/composer.json deleted file mode 100644 index 9d44a79209..0000000000 --- a/application/data/omeka-assets/composer.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "omeka/omeka-assets", - "type": "composer-plugin", - "version": "1.0.0", - "description": "Composer plugin to download external assets for Omeka S modules and themes", - "license": "GPL-3.0-or-later", - "require": { - "php": ">=8.1", - "composer-plugin-api": "^2.0" - }, - "require-dev": { - "composer/composer": "^2.0" - }, - "autoload": { - "psr-4": { - "Omeka\\OmekaAssets\\": "src/" - } - }, - "extra": { - "class": "Omeka\\OmekaAssets\\OmekaAssetsPlugin" - } -} diff --git a/application/data/omeka-assets/src/OmekaAssetsPlugin.php b/application/data/omeka-assets/src/OmekaAssetsPlugin.php deleted file mode 100644 index 9a8abe120c..0000000000 --- a/application/data/omeka-assets/src/OmekaAssetsPlugin.php +++ /dev/null @@ -1,245 +0,0 @@ -composer = $composer; - $this->io = $io; - } - - public function deactivate(Composer $composer, IOInterface $io) - { - } - - public function uninstall(Composer $composer, IOInterface $io) - { - } - - public static function getSubscribedEvents() - { - return [ - PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', - PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', - ]; - } - - /** - * Handle post-install for packages with omeka-assets. - */ - public function onPostPackageInstall(PackageEvent $event) - { - $package = $event->getOperation()->getPackage(); - $this->handleOmekaAssets($package); - } - - /** - * Handle post-update for packages with omeka-assets. - */ - public function onPostPackageUpdate(PackageEvent $event) - { - $package = $event->getOperation()->getTargetPackage(); - $this->handleOmekaAssets($package); - } - - /** - * Download and install assets defined in extra.omeka-assets. - */ - protected function handleOmekaAssets($package) - { - $extra = $package->getExtra(); - if (empty($extra['omeka-assets']) || !is_array($extra['omeka-assets'])) { - return; - } - - $installPath = $this->composer->getInstallationManager()->getInstallPath($package); - - foreach ($extra['omeka-assets'] as $destination => $url) { - $destPath = $installPath . '/' . ltrim($destination, '/'); - $isDirectory = substr($destination, -1) === '/'; - $isArchive = preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); - - $this->io->write(sprintf( - 'Downloading asset %s for %s...', - basename($url), - $package->getPrettyName() - )); - - try { - if ($isDirectory && $isArchive) { - $this->downloadAndExtract($url, $destPath); - } elseif ($isDirectory) { - // Directory destination + non-archive URL: copy file into directory. - $this->downloadFile($url, $destPath . basename($url)); - } else { - $this->downloadFile($url, $destPath); - } - } catch (\Exception $e) { - $this->io->writeError(sprintf( - 'Failed to download asset %s: %s', - $url, - $e->getMessage() - )); - } - } - } - - /** - * Download a single file using composer HttpDownloader. - */ - protected function downloadFile(string $url, string $destPath): void - { - $filesystem = new Filesystem(); - $filesystem->ensureDirectoryExists(dirname($destPath)); - - $httpDownloader = new HttpDownloader($this->io, $this->composer->getConfig()); - $httpDownloader->copy($url, $destPath); - } - - /** - * Download and extract an archive using composer utilities. - * - * If the archive contains a single root directory, its contents are - * extracted directly to the destination (stripping the root directory). - */ - protected function downloadAndExtract(string $url, string $destPath): void - { - $filesystem = new Filesystem(); - - $tempFile = sys_get_temp_dir() . '/' . basename($url); - $tempDir = sys_get_temp_dir() . '/omeka_extract_' . uniqid(); - - $filesystem->ensureDirectoryExists($tempDir); - - $httpDownloader = new HttpDownloader($this->io, $this->composer->getConfig()); - $httpDownloader->copy($url, $tempFile); - - // Use composer archive extractor via process. - $process = new ProcessExecutor($this->io); - - if (preg_match('/\.zip$/i', $url)) { - // Try unzip command first, fallback to php ZipArchive. - $command = sprintf('unzip -o -q %s -d %s 2>&1', escapeshellarg($tempFile), escapeshellarg($tempDir)); - if ($process->execute($command) !== 0) { - // Fallback to ZipArchive if unzip is not available. - if (!class_exists('ZipArchive')) { - $filesystem->unlink($tempFile); - $filesystem->removeDirectory($tempDir); - throw new \RuntimeException('Cannot extract zip: unzip command failed and ZipArchive not available'); - } - $zip = new \ZipArchive(); - if ($zip->open($tempFile) !== true) { - $filesystem->unlink($tempFile); - $filesystem->removeDirectory($tempDir); - throw new \RuntimeException('Failed to open zip archive'); - } - $zip->extractTo($tempDir); - $zip->close(); - } - } elseif (preg_match('/\.(tar\.gz|tgz)$/i', $url)) { - $command = sprintf('tar -xzf %s -C %s 2>&1', escapeshellarg($tempFile), escapeshellarg($tempDir)); - if ($process->execute($command) !== 0) { - // Fallback to PharData. - $phar = new \PharData($tempFile); - $phar->extractTo($tempDir); - } - } - - $filesystem->unlink($tempFile); - - // Check if archive has a single root directory and strip it. - $sourceDir = $this->getArchiveSourceDir($tempDir); - - // Move contents to destination. - $filesystem->ensureDirectoryExists($destPath); - $this->moveDirectoryContents($sourceDir, $destPath, $filesystem); - - // Cleanup temp directory. - $filesystem->removeDirectory($tempDir); - } - - /** - * Get the source directory for extraction. - * - * If the extracted archive contains a single root directory, return that - * directory path (to strip the root). Otherwise return the temp directory. - */ - protected function getArchiveSourceDir(string $tempDir): string - { - $entries = array_diff(scandir($tempDir), ['.', '..']); - - // If single entry and it's a directory, use it as source (strip root). - if (count($entries) === 1) { - $entry = reset($entries); - $entryPath = $tempDir . '/' . $entry; - if (is_dir($entryPath)) { - return $entryPath; - } - } - - return $tempDir; - } - - /** - * Move contents from source directory to destination. - */ - protected function moveDirectoryContents(string $source, string $dest, Filesystem $filesystem): void - { - $entries = array_diff(scandir($source), ['.', '..']); - - foreach ($entries as $entry) { - $srcPath = $source . '/' . $entry; - $dstPath = $dest . '/' . $entry; - - if (is_dir($srcPath)) { - $filesystem->ensureDirectoryExists($dstPath); - $this->moveDirectoryContents($srcPath, $dstPath, $filesystem); - @rmdir($srcPath); - } else { - // Remove existing file if any. - if (file_exists($dstPath)) { - $filesystem->unlink($dstPath); - } - rename($srcPath, $dstPath); - } - } - } -} diff --git a/application/data/scripts/install-omeka-assets.php b/application/data/scripts/install-omeka-assets.php deleted file mode 100644 index 9893080e18..0000000000 --- a/application/data/scripts/install-omeka-assets.php +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env php -isDot() || !$entry->isDir()) { - continue; - } - $paths[$entry->getBasename()] = $entry->getPathname(); - } - } - } - - foreach ($themeDirs as $dir) { - if (is_dir($dir)) { - foreach (new DirectoryIterator($dir) as $entry) { - if ($entry->isDot() || !$entry->isDir()) { - continue; - } - $paths['theme:' . $entry->getBasename()] = $entry->getPathname(); - } - } - } -} else { - foreach ($names as $name) { - if ($isTheme) { - $possiblePaths = [ - OMEKA_PATH . '/themes/' . $name, - OMEKA_PATH . '/composer-addons/themes/' . $name, - ]; - } else { - $possiblePaths = [ - OMEKA_PATH . '/modules/' . $name, - OMEKA_PATH . '/composer-addons/modules/' . $name, - ]; - } - - $found = false; - foreach ($possiblePaths as $path) { - if (is_dir($path)) { - $paths[$name] = $path; - $found = true; - break; - } - } - - if (!$found) { - echo "Error: " . ($isTheme ? 'Theme' : 'Module') . " '$name' not found.\n"; - exit(1); - } - } -} - -// Process each path -$totalInstalled = 0; -$totalSkipped = 0; -$totalFailed = 0; - -foreach ($paths as $name => $path) { - $composerJson = $path . '/composer.json'; - if (!file_exists($composerJson)) { - continue; - } - - $json = json_decode(file_get_contents($composerJson), true); - if (empty($json['extra']['omeka-assets'])) { - continue; - } - - echo "Processing $name...\n"; - - if ($force) { - // Remove existing assets to force re-download - foreach ($json['extra']['omeka-assets'] as $dest => $url) { - $destPath = $path . '/' . ltrim($dest, '/'); - if (substr($dest, -1) === '/') { - if (is_dir($destPath)) { - echo " Removing $dest for re-download\n"; - removeDirectory($destPath); - } - } else { - if (file_exists($destPath)) { - echo " Removing $dest for re-download\n"; - unlink($destPath); - } - } - } - } - - $result = $installer->installFromPath($path, $name); - - if ($result) { - $totalInstalled++; - } else { - $totalFailed++; - } -} - -echo "\nDone. Installed: $totalInstalled, Failed: $totalFailed\n"; - -exit($totalFailed > 0 ? 1 : 0); - -/** - * Recursively remove a directory. - */ -function removeDirectory(string $dir): void -{ - if (!is_dir($dir)) { - return; - } - $entries = array_diff(scandir($dir), ['.', '..']); - foreach ($entries as $entry) { - $path = $dir . '/' . $entry; - if (is_dir($path)) { - removeDirectory($path); - } else { - @unlink($path); - } - } - @rmdir($dir); -} diff --git a/application/src/Module/OmekaAssetsInstaller.php b/application/src/Module/OmekaAssetsInstaller.php deleted file mode 100644 index 878d64ad20..0000000000 --- a/application/src/Module/OmekaAssetsInstaller.php +++ /dev/null @@ -1,343 +0,0 @@ -logger = $logger; - } - - /** - * Install omeka-assets for a module. - * - * @param Module $module The module to install assets for - * @return bool True if all assets were installed successfully - */ - public function install(Module $module): bool - { - $modulePath = dirname($module->getModuleFilePath()); - return $this->installFromPath($modulePath, $module->getId()); - } - - /** - * Install omeka-assets from a module/theme path. - * - * @param string $path Path to the module or theme directory - * @param string|null $name Optional name for logging - * @return bool True if all assets were installed successfully - */ - public function installFromPath(string $path, ?string $name = null): bool - { - $composerJsonPath = $path . '/composer.json'; - if (!file_exists($composerJsonPath)) { - return true; // No composer.json, nothing to do - } - - $composerJson = json_decode(file_get_contents($composerJsonPath), true); - if (!$composerJson) { - return true; // Invalid JSON, skip - } - - $omekaAssets = $composerJson['extra']['omeka-assets'] ?? null; - if (!$omekaAssets || !is_array($omekaAssets)) { - return true; // No omeka-assets defined - } - - $name = $name ?: basename($path); - $success = true; - - foreach ($omekaAssets as $destination => $url) { - $destPath = $path . '/' . ltrim($destination, '/'); - - // Check if asset already exists - if ($this->assetExists($destPath, $destination)) { - $this->logger->info(sprintf( - 'Asset already exists for %s: %s', - $name, - $destination - )); - continue; - } - - $this->logger->info(sprintf( - 'Downloading asset for %s: %s', - $name, - basename($url) - )); - - try { - $isDirectory = substr($destination, -1) === '/'; - $isArchive = (bool) preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); - - if ($isDirectory && $isArchive) { - $this->downloadAndExtract($url, $destPath); - } elseif ($isDirectory) { - $this->downloadFile($url, $destPath . basename($url)); - } else { - $this->downloadFile($url, $destPath); - } - } catch (\Exception $e) { - $this->logger->err(sprintf( - 'Failed to download asset %s for %s: %s', - $url, - $name, - $e->getMessage() - )); - $success = false; - } - } - - return $success; - } - - /** - * Check if an asset already exists. - */ - protected function assetExists(string $destPath, string $destination): bool - { - $isDirectory = substr($destination, -1) === '/'; - - if ($isDirectory) { - // For directories, check if the directory exists and is not empty - if (is_dir($destPath)) { - $entries = array_diff(scandir($destPath), ['.', '..']); - return count($entries) > 0; - } - return false; - } - - return file_exists($destPath); - } - - /** - * Download a single file. - */ - protected function downloadFile(string $url, string $destPath): void - { - $destDir = dirname($destPath); - if (!is_dir($destDir)) { - mkdir($destDir, 0755, true); - } - - $content = $this->fetchUrl($url); - file_put_contents($destPath, $content); - } - - /** - * Download and extract an archive. - */ - protected function downloadAndExtract(string $url, string $destPath): void - { - $tempFile = sys_get_temp_dir() . '/' . basename($url); - $tempDir = sys_get_temp_dir() . '/omeka_extract_' . uniqid(); - - mkdir($tempDir, 0755, true); - - try { - $content = $this->fetchUrl($url); - file_put_contents($tempFile, $content); - - if (preg_match('/\.zip$/i', $url)) { - $this->extractZip($tempFile, $tempDir); - } elseif (preg_match('/\.(tar\.gz|tgz)$/i', $url)) { - $this->extractTarGz($tempFile, $tempDir); - } - - @unlink($tempFile); - - // Check if archive has a single root directory and strip it - $sourceDir = $this->getArchiveSourceDir($tempDir); - - // Move contents to destination - if (!is_dir($destPath)) { - mkdir($destPath, 0755, true); - } - $this->moveDirectoryContents($sourceDir, $destPath); - - // Cleanup temp directory - $this->removeDirectory($tempDir); - } catch (\Exception $e) { - @unlink($tempFile); - $this->removeDirectory($tempDir); - throw $e; - } - } - - /** - * Fetch URL content. - */ - protected function fetchUrl(string $url): string - { - $context = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: Omeka S\r\n", - 'follow_location' => true, - 'timeout' => 30, - ], - 'ssl' => [ - 'verify_peer' => true, - 'verify_peer_name' => true, - ], - ]); - - $content = @file_get_contents($url, false, $context); - if ($content === false) { - throw new \RuntimeException('Failed to download: ' . $url); - } - - return $content; - } - - /** - * Extract a zip file. - */ - protected function extractZip(string $zipFile, string $destDir): void - { - // Try command line first - $command = sprintf( - 'unzip -o -q %s -d %s 2>&1', - escapeshellarg($zipFile), - escapeshellarg($destDir) - ); - exec($command, $output, $exitCode); - - if ($exitCode === 0) { - return; - } - - // Fallback to ZipArchive - if (!class_exists('ZipArchive')) { - throw new \RuntimeException('Cannot extract zip: unzip command failed and ZipArchive not available'); - } - - $zip = new \ZipArchive(); - if ($zip->open($zipFile) !== true) { - throw new \RuntimeException('Failed to open zip archive'); - } - $zip->extractTo($destDir); - $zip->close(); - } - - /** - * Extract a tar.gz file. - */ - protected function extractTarGz(string $tarFile, string $destDir): void - { - // Try command line first - $command = sprintf( - 'tar -xzf %s -C %s 2>&1', - escapeshellarg($tarFile), - escapeshellarg($destDir) - ); - exec($command, $output, $exitCode); - - if ($exitCode === 0) { - return; - } - - // Fallback to PharData - $phar = new \PharData($tarFile); - $phar->extractTo($destDir); - } - - /** - * Get the source directory for extraction. - * - * If the extracted archive contains a single root directory, return that - * directory path (to strip the root). Otherwise return the temp directory. - */ - protected function getArchiveSourceDir(string $tempDir): string - { - $entries = array_diff(scandir($tempDir), ['.', '..']); - - // If single entry and it's a directory, use it as source (strip root) - if (count($entries) === 1) { - $entry = reset($entries); - $entryPath = $tempDir . '/' . $entry; - if (is_dir($entryPath)) { - return $entryPath; - } - } - - return $tempDir; - } - - /** - * Move contents from source directory to destination. - */ - protected function moveDirectoryContents(string $source, string $dest): void - { - $entries = array_diff(scandir($source), ['.', '..']); - - foreach ($entries as $entry) { - $srcPath = $source . '/' . $entry; - $dstPath = $dest . '/' . $entry; - - if (is_dir($srcPath)) { - if (!is_dir($dstPath)) { - mkdir($dstPath, 0755, true); - } - $this->moveDirectoryContents($srcPath, $dstPath); - @rmdir($srcPath); - } else { - if (file_exists($dstPath)) { - @unlink($dstPath); - } - rename($srcPath, $dstPath); - } - } - } - - /** - * Recursively remove a directory. - */ - protected function removeDirectory(string $dir): void - { - if (!is_dir($dir)) { - return; - } - - $entries = array_diff(scandir($dir), ['.', '..']); - foreach ($entries as $entry) { - $path = $dir . '/' . $entry; - if (is_dir($path)) { - $this->removeDirectory($path); - } else { - @unlink($path); - } - } - @rmdir($dir); - } -} diff --git a/application/test/OmekaTest/Composer/OmekaAssetsTest.php b/application/test/OmekaTest/Composer/OmekaAssetsTest.php deleted file mode 100644 index 46163bd8b6..0000000000 --- a/application/test/OmekaTest/Composer/OmekaAssetsTest.php +++ /dev/null @@ -1,193 +0,0 @@ -assertEquals($expectDirectory, $isDirectory, "Directory detection for: $destination"); - $this->assertEquals($expectArchive, $isArchive, "Archive detection for: $url"); - } - - public function omekaAssetsProvider(): array - { - return [ - // [destination, url, expectDirectory, expectArchive] - [ - 'asset/vendor/lib/file.min.js', - 'https://example.com/file.min.js', - false, // not a directory - false, // not an archive - ], - [ - 'asset/vendor/lib/', - 'https://example.com/archive.zip', - true, // is a directory - true, // is an archive - ], - [ - 'asset/vendor/mirador/', - 'https://example.com/mirador-2.7.0.tar.gz', - true, // is a directory - true, // is an archive - ], - [ - 'asset/vendor/lib/', - 'https://example.com/file.tgz', - true, // is a directory - true, // is an archive - ], - [ - 'asset/css/custom.css', - 'https://example.com/styles.css', - false, // not a directory - false, // not an archive - ], - // Third case: directory + non-archive = copy file into directory - [ - 'asset/vendor/lib/', - 'https://example.com/jquery.min.js', - true, // is a directory - false, // not an archive → file copied into directory - ], - ]; - } - - /** - * @dataProvider omekaAssetsActionProvider - */ - public function testOmekaAssetsActionDetection(string $destination, string $url, string $expectedAction): void - { - $isDirectory = substr($destination, -1) === '/'; - $isArchive = (bool) preg_match('/\.(zip|tar\.gz|tgz)$/i', $url); - - if ($isDirectory && $isArchive) { - $action = 'extract'; - } elseif ($isDirectory) { - $action = 'copy_into_dir'; - } else { - $action = 'download'; - } - - $this->assertEquals($expectedAction, $action, "Action for: $destination <- $url"); - } - - public function omekaAssetsActionProvider(): array - { - return [ - // [destination, url, expectedAction] - ['asset/vendor/lib/file.min.js', 'https://example.com/file.min.js', 'download'], - ['asset/vendor/lib/', 'https://example.com/archive.zip', 'extract'], - ['asset/vendor/lib/', 'https://example.com/archive.tar.gz', 'extract'], - ['asset/vendor/lib/', 'https://example.com/jquery.min.js', 'copy_into_dir'], - ['asset/vendor/lib/', 'https://example.com/styles.css', 'copy_into_dir'], - ]; - } - - public function testDestinationFilenameRename(): void - { - // When destination has a different filename than the URL, it renames. - $destination = 'asset/vendor/lib/jquery.autocomplete.min.js'; - $url = 'https://example.com/jquery.autocomplete-1.5.0.min.js'; - - // The destination path is used as-is (not the URL basename). - $destPath = '/install/path/' . ltrim($destination, '/'); - $this->assertEquals('/install/path/asset/vendor/lib/jquery.autocomplete.min.js', $destPath); - $this->assertNotEquals(basename($url), basename($destPath)); - } - - public function testArchiveSingleRootDirectoryStripping(): void - { - // Simulate the logic that detects a single root directory. - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - mkdir($tempDir . '/mirador-2.7.0'); - touch($tempDir . '/mirador-2.7.0/file1.js'); - touch($tempDir . '/mirador-2.7.0/file2.js'); - - $entries = array_diff(scandir($tempDir), ['.', '..']); - - // Single entry that is a directory → should be stripped. - $this->assertCount(1, $entries); - $entry = reset($entries); - $this->assertTrue(is_dir($tempDir . '/' . $entry)); - - // The source dir should be the nested directory. - $sourceDir = $tempDir . '/' . $entry; - $this->assertEquals($tempDir . '/mirador-2.7.0', $sourceDir); - - // Cleanup. - unlink($tempDir . '/mirador-2.7.0/file1.js'); - unlink($tempDir . '/mirador-2.7.0/file2.js'); - rmdir($tempDir . '/mirador-2.7.0'); - rmdir($tempDir); - } - - public function testArchiveMultipleEntriesNoStripping(): void - { - // Simulate an archive with multiple root entries. - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - touch($tempDir . '/file1.js'); - touch($tempDir . '/file2.js'); - - $entries = array_diff(scandir($tempDir), ['.', '..']); - - // Multiple entries → no stripping, use tempDir as source. - $this->assertCount(2, $entries); - - // Cleanup. - unlink($tempDir . '/file1.js'); - unlink($tempDir . '/file2.js'); - rmdir($tempDir); - } - - public function testOmekaAssetsConfigParsing(): void - { - $composerJson = [ - 'extra' => [ - 'omeka-assets' => [ - 'asset/vendor/jquery-autocomplete/jquery.autocomplete.min.js' => 'https://example.com/jquery.autocomplete.min.js', - 'asset/vendor/mirador/' => 'https://example.com/mirador.zip', - ], - ], - ]; - - $extra = $composerJson['extra']; - $this->assertArrayHasKey('omeka-assets', $extra); - $this->assertIsArray($extra['omeka-assets']); - $this->assertCount(2, $extra['omeka-assets']); - - foreach ($extra['omeka-assets'] as $destination => $url) { - $this->assertIsString($destination); - $this->assertIsString($url); - $this->assertStringStartsWith('https://', $url); - } - } - - public function testEmptyOmekaAssetsConfig(): void - { - $composerJson = [ - 'extra' => [], - ]; - - $extra = $composerJson['extra']; - $hasAssets = !empty($extra['omeka-assets']) && is_array($extra['omeka-assets']); - $this->assertFalse($hasAssets); - } -} diff --git a/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php b/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php deleted file mode 100644 index 3387ec1512..0000000000 --- a/application/test/OmekaTest/Module/OmekaAssetsInstallerTest.php +++ /dev/null @@ -1,253 +0,0 @@ -createMock(\Laminas\Log\LoggerInterface::class); - return new OmekaAssetsInstaller($logger); - } - - public function testInstallFromPathWithNoComposerJson(): void - { - $installer = $this->getInstaller(); - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - - // No composer.json, should return true (nothing to do) - $result = $installer->installFromPath($tempDir); - $this->assertTrue($result); - - rmdir($tempDir); - } - - public function testInstallFromPathWithEmptyOmekaAssets(): void - { - $installer = $this->getInstaller(); - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - - // Create composer.json without omeka-assets - file_put_contents($tempDir . '/composer.json', json_encode([ - 'name' => 'test/module', - 'extra' => [], - ])); - - $result = $installer->installFromPath($tempDir); - $this->assertTrue($result); - - unlink($tempDir . '/composer.json'); - rmdir($tempDir); - } - - public function testInstallFromPathWithExistingAsset(): void - { - $installer = $this->getInstaller(); - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - mkdir($tempDir . '/asset/vendor', 0755, true); - - // Create a file that already exists - file_put_contents($tempDir . '/asset/vendor/file.js', 'existing'); - - // Create composer.json with omeka-assets pointing to existing file - file_put_contents($tempDir . '/composer.json', json_encode([ - 'name' => 'test/module', - 'extra' => [ - 'omeka-assets' => [ - 'asset/vendor/file.js' => 'https://example.com/file.js', - ], - ], - ])); - - // Should return true (asset exists, skip download) - $result = $installer->installFromPath($tempDir); - $this->assertTrue($result); - - // Verify the file was NOT overwritten (still has original content) - $this->assertEquals('existing', file_get_contents($tempDir . '/asset/vendor/file.js')); - - // Cleanup - unlink($tempDir . '/asset/vendor/file.js'); - unlink($tempDir . '/composer.json'); - rmdir($tempDir . '/asset/vendor'); - rmdir($tempDir . '/asset'); - rmdir($tempDir); - } - - public function testInstallFromPathWithExistingDirectory(): void - { - $installer = $this->getInstaller(); - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - mkdir($tempDir . '/asset/vendor/lib', 0755, true); - - // Create a file in the directory to mark it as non-empty - file_put_contents($tempDir . '/asset/vendor/lib/existing.js', 'content'); - - // Create composer.json with omeka-assets pointing to existing directory - file_put_contents($tempDir . '/composer.json', json_encode([ - 'name' => 'test/module', - 'extra' => [ - 'omeka-assets' => [ - 'asset/vendor/lib/' => 'https://example.com/archive.zip', - ], - ], - ])); - - // Should return true (directory not empty, skip download) - $result = $installer->installFromPath($tempDir); - $this->assertTrue($result); - - // Cleanup - unlink($tempDir . '/asset/vendor/lib/existing.js'); - unlink($tempDir . '/composer.json'); - rmdir($tempDir . '/asset/vendor/lib'); - rmdir($tempDir . '/asset/vendor'); - rmdir($tempDir . '/asset'); - rmdir($tempDir); - } - - public function testAssetExistsForFile(): void - { - $installer = $this->getInstaller(); - - // Use reflection to test protected method - $method = new \ReflectionMethod($installer, 'assetExists'); - $method->setAccessible(true); - - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - - // Non-existent file - $this->assertFalse($method->invoke($installer, $tempDir . '/file.js', 'file.js')); - - // Existent file - file_put_contents($tempDir . '/file.js', 'content'); - $this->assertTrue($method->invoke($installer, $tempDir . '/file.js', 'file.js')); - - // Cleanup - unlink($tempDir . '/file.js'); - rmdir($tempDir); - } - - public function testAssetExistsForDirectory(): void - { - $installer = $this->getInstaller(); - - // Use reflection to test protected method - $method = new \ReflectionMethod($installer, 'assetExists'); - $method->setAccessible(true); - - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - - // Non-existent directory - $this->assertFalse($method->invoke($installer, $tempDir . '/lib/', 'lib/')); - - // Empty directory (should be considered as not existing) - mkdir($tempDir . '/lib'); - $this->assertFalse($method->invoke($installer, $tempDir . '/lib/', 'lib/')); - - // Non-empty directory - file_put_contents($tempDir . '/lib/file.js', 'content'); - $this->assertTrue($method->invoke($installer, $tempDir . '/lib/', 'lib/')); - - // Cleanup - unlink($tempDir . '/lib/file.js'); - rmdir($tempDir . '/lib'); - rmdir($tempDir); - } - - public function testArchiveSourceDirWithSingleRootDirectory(): void - { - $installer = $this->getInstaller(); - - // Use reflection to test protected method - $method = new \ReflectionMethod($installer, 'getArchiveSourceDir'); - $method->setAccessible(true); - - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - mkdir($tempDir . '/mirador-2.7.0'); - touch($tempDir . '/mirador-2.7.0/file1.js'); - - // Single root directory should return that directory (for stripping) - $result = $method->invoke($installer, $tempDir); - $this->assertEquals($tempDir . '/mirador-2.7.0', $result); - - // Cleanup - unlink($tempDir . '/mirador-2.7.0/file1.js'); - rmdir($tempDir . '/mirador-2.7.0'); - rmdir($tempDir); - } - - public function testArchiveSourceDirWithMultipleEntries(): void - { - $installer = $this->getInstaller(); - - // Use reflection to test protected method - $method = new \ReflectionMethod($installer, 'getArchiveSourceDir'); - $method->setAccessible(true); - - $tempDir = sys_get_temp_dir() . '/omeka_test_' . uniqid(); - mkdir($tempDir); - touch($tempDir . '/file1.js'); - touch($tempDir . '/file2.js'); - - // Multiple entries should return the tempDir itself - $result = $method->invoke($installer, $tempDir); - $this->assertEquals($tempDir, $result); - - // Cleanup - unlink($tempDir . '/file1.js'); - unlink($tempDir . '/file2.js'); - rmdir($tempDir); - } - - public function testMoveDirectoryContents(): void - { - $installer = $this->getInstaller(); - - // Use reflection to test protected method - $method = new \ReflectionMethod($installer, 'moveDirectoryContents'); - $method->setAccessible(true); - - $srcDir = sys_get_temp_dir() . '/omeka_test_src_' . uniqid(); - $dstDir = sys_get_temp_dir() . '/omeka_test_dst_' . uniqid(); - - mkdir($srcDir); - mkdir($srcDir . '/subdir'); - file_put_contents($srcDir . '/file1.js', 'content1'); - file_put_contents($srcDir . '/subdir/file2.js', 'content2'); - - mkdir($dstDir); - - $method->invoke($installer, $srcDir, $dstDir); - - // Check files were moved - $this->assertFileExists($dstDir . '/file1.js'); - $this->assertFileExists($dstDir . '/subdir/file2.js'); - $this->assertEquals('content1', file_get_contents($dstDir . '/file1.js')); - $this->assertEquals('content2', file_get_contents($dstDir . '/subdir/file2.js')); - - // Cleanup - unlink($dstDir . '/file1.js'); - unlink($dstDir . '/subdir/file2.js'); - rmdir($dstDir . '/subdir'); - rmdir($dstDir); - // srcDir should be empty now (files moved) - @rmdir($srcDir . '/subdir'); - @rmdir($srcDir); - } -} diff --git a/composer-addons/README.md b/composer-addons/README.md index 24b71b5d89..e5d42c4b05 100644 --- a/composer-addons/README.md +++ b/composer-addons/README.md @@ -7,16 +7,16 @@ Add-ons are modules and themes managed by composer when they have the type The use of add-ons allows to use a single command `composer require xxx/yyy` to manage an Omeka instance, to avoid duplication of dependencies, and to improve speed of Omeka init. With composer, add-ons are installed automatically -under `composer-addons/modules/` and `composer-addons/themes/` and their own dependencies are -shared with the Omeka ones in `vendor/`. +under `composer-addons/modules/` and `composer-addons/themes/` and their own +dependencies are shared with the Omeka ones in `vendor/`. Omeka S still supports classic locations `modules/` and `themes/`. When a module or a theme with the same name is located in `modules/` or `themes/`, it is prioritary and the composer add-on is skipped. -If a module has no composer.json file or is not available on https://packagist.org, -it still can be managed via composer via the key `repositories` (see -https://getcomposer.org/doc/04-schema.md#repositories). +If a module has no composer.json file or is not available on +https://packagist.org, it still can be managed via composer via the key +`repositories` (see https://getcomposer.org/doc/04-schema.md#repositories). Version compatibility @@ -50,7 +50,6 @@ The file composer.json supports optional specific keys under key `extra`: - `installer-name`: directory to use when different from project name. - `label`: display label when different from project name. - `configurable`: boolean to specify if the module is configurable. -- `omeka-assets`: list external assets to download (see section Assets below). Specific keys for themes: @@ -62,48 +61,38 @@ If an extra key is not available, a check is done for an equivalent in file set. -Assets ------- +External assets +--------------- -For assets (libraries for css/img/js/fonts/etc.), modules and themes can define -external files to download automatically during composer installation using the -`omeka-assets` key under `extra`: +Modules and themes that need external js/css/fonts/img libraries can use another +composer plugins like [sempia/external-assets](https://gitlab.com/sempia/composer-plugin-external-assets), +a lightweight solution, or [civicrm/composer-downloads-plugin](https://github.com/civicrm/composer-downloads-plugin), +a full featured tool (variables, ignore patterns, executable flag), or any other +one. + +Example using `sempia/external-assets`: ```json { + "require": { + "sempia/external-assets": "^1.0" + }, "extra": { - "omeka-assets": { - "asset/vendor/lib/custom.min.js": "https://example.com/v3.4.0/file.min.js", - "asset/vendor/lib/": "https://example.com/v3.4.1/archive.zip", - "asset/vendor/scripts/": "https://example.com/script.js" + "external-assets": { + "asset/vendor/mirador/": "https://github.com/ProjectMirador/mirador/releases/download/v3.3.0/mirador.zip", + "asset/vendor/lib/jquery.min.js": "https://cdn.example.com/jquery-3.7.0.min.js" } } } ``` -The key is the destination path relative to the add-on directory, the value is -the url to download. - -- If destination ends with a filename, download url and rename to that name. -- If destination ends with `/` and url has `.zip`/`.tar.gz`/`.tgz`, extract it. - When the archive contains a single root directory, it is stripped. -- If destination ends with `/` and url is a file, copy it into that directory. - -This mechanism avoids the need for custom `repositories` in composer.json, -which are not inherited from composer dependencies. - -It is recommended not to use nodejs to install assets to be consistent with -Omeka, that should be manageable on a server without nodejs. - -The assets feature is provided by the internal separate composer plugin `omeka/omeka-assets`. - Manual installation ------------------- For add-ons installed manually via `git clone` in directory `modules/` or -`themes/`, dependencies and assets are not downloaded automatically. Use the -following scripts from the Omeka root: +`themes/`, dependencies are not downloaded automatically. Use the following +script from the Omeka root: ```sh # 1. Clone the add-on @@ -111,9 +100,6 @@ git clone https://gitlab.com/user/MyModule modules/MyModule # 2. Install composer dependencies (other modules, libraries) php application/data/scripts/install-addon-deps.php MyModule - -# 3. Install external assets (js, css, etc.) -php application/data/scripts/install-omeka-assets.php MyModule ``` ### Install dependencies @@ -129,26 +115,10 @@ php application/data/scripts/install-addon-deps.php --theme theme-name php application/data/scripts/install-addon-deps.php --dry-run ModuleName ``` -### Install assets - -```sh -# Module -php application/data/scripts/install-omeka-assets.php ModuleName - -# Theme -php application/data/scripts/install-omeka-assets.php --theme theme-name - -# All modules and themes -php application/data/scripts/install-omeka-assets.php --all - -# Force re-download -php application/data/scripts/install-omeka-assets.php --force ModuleName -``` - Funding ------- This feature was funded for the [digital library Manioc](https://manioc.org) of the [Université des Antilles](https://www.univ-antilles.fr) (subvention -Agence bibliographique de l’enseignement supérieur [Abes](https://abes.fr)). +Agence bibliographique de l'enseignement supérieur [Abes](https://abes.fr)). diff --git a/composer.json b/composer.json index 9c750bde11..30419bc499 100644 --- a/composer.json +++ b/composer.json @@ -49,11 +49,9 @@ "laminas/laminas-validator": "^2.8", "laminas/laminas-view": "^2.8", "omeka/composer-addon-installer": "*", - "omeka/omeka-assets": "*", "omeka-s-themes/default": "dev-develop", "beberlei/doctrineextensions": "^1.0", - "fileeye/pel": "^0.12.0", - "composer/composer": "^2.9" + "fileeye/pel": "^0.12.0" }, "require-dev": { "phpunit/phpunit": "^9", @@ -67,8 +65,7 @@ "config": { "platform": {"php": "8.1"}, "allow-plugins": { - "omeka/composer-addon-installer": true, - "omeka/omeka-assets": true + "omeka/composer-addon-installer": true } }, "repositories": [ @@ -84,10 +81,6 @@ "type": "path", "url": "application/data/composer-addon-installer" }, - { - "type": "path", - "url": "application/data/omeka-assets" - }, { "type": "vcs", "url": "https://github.com/omeka/laminas-log" diff --git a/composer.lock b/composer.lock index 21a5658d60..c7ba9a3a30 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "912a4912f08aed45284d057b26542fc1", + "content-hash": "837a91aad99679c51b33ad90066fb965", "packages": [ { "name": "beberlei/doctrineextensions", @@ -117,408 +117,6 @@ ], "time": "2025-02-20T17:42:39+00:00" }, - { - "name": "composer/ca-bundle", - "version": "1.5.10", - "source": { - "type": "git", - "url": "https://github.com/composer/ca-bundle.git", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/961a5e4056dd2e4a2eedcac7576075947c28bf63", - "reference": "961a5e4056dd2e4a2eedcac7576075947c28bf63", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "ext-pcre": "*", - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8 || ^9", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\CaBundle\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", - "keywords": [ - "cabundle", - "cacert", - "certificate", - "ssl", - "tls" - ], - "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.10" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-12-08T15:06:51+00:00" - }, - { - "name": "composer/class-map-generator", - "version": "1.7.1", - "source": { - "type": "git", - "url": "https://github.com/composer/class-map-generator.git", - "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/8f5fa3cc214230e71f54924bd0197a3bcc705eb1", - "reference": "8f5fa3cc214230e71f54924bd0197a3bcc705eb1", - "shasum": "" - }, - "require": { - "composer/pcre": "^2.1 || ^3.1", - "php": "^7.2 || ^8.0", - "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-deprecation-rules": "^1 || ^2", - "phpstan/phpstan-phpunit": "^1 || ^2", - "phpstan/phpstan-strict-rules": "^1.1 || ^2", - "phpunit/phpunit": "^8", - "symfony/filesystem": "^5.4 || ^6 || ^7 || ^8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\ClassMapGenerator\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "Utilities to scan PHP code and generate class maps.", - "keywords": [ - "classmap" - ], - "support": { - "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.1" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-12-29T13:15:25+00:00" - }, - { - "name": "composer/composer", - "version": "2.9.5", - "source": { - "type": "git", - "url": "https://github.com/composer/composer.git", - "reference": "72a8f8e653710e18d83e5dd531eb5a71fc3223e6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/72a8f8e653710e18d83e5dd531eb5a71fc3223e6", - "reference": "72a8f8e653710e18d83e5dd531eb5a71fc3223e6", - "shasum": "" - }, - "require": { - "composer/ca-bundle": "^1.5", - "composer/class-map-generator": "^1.4.0", - "composer/metadata-minifier": "^1.0", - "composer/pcre": "^2.3 || ^3.3", - "composer/semver": "^3.3", - "composer/spdx-licenses": "^1.5.7", - "composer/xdebug-handler": "^2.0.2 || ^3.0.3", - "ext-json": "*", - "justinrainbow/json-schema": "^6.5.1", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1.0 || ^2.0 || ^3.0", - "react/promise": "^3.3", - "seld/jsonlint": "^1.4", - "seld/phar-utils": "^1.2", - "seld/signal-handler": "^2.0", - "symfony/console": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0", - "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", - "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.1.10 || ^8.0", - "symfony/polyfill-php73": "^1.24", - "symfony/polyfill-php80": "^1.24", - "symfony/polyfill-php81": "^1.24", - "symfony/polyfill-php84": "^1.30", - "symfony/process": "^5.4.47 || ^6.4.25 || ^7.1.10 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11.8", - "phpstan/phpstan-deprecation-rules": "^1.2.0", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.0", - "phpstan/phpstan-symfony": "^1.4.0", - "symfony/phpunit-bridge": "^6.4.25 || ^7.3.3 || ^8.0" - }, - "suggest": { - "ext-curl": "Provides HTTP support (will fallback to PHP streams if missing)", - "ext-openssl": "Enables access to repositories and packages over HTTPS", - "ext-zip": "Allows direct extraction of ZIP archives (unzip/7z binaries will be used instead if available)", - "ext-zlib": "Enables gzip for HTTP requests" - }, - "bin": [ - "bin/composer" - ], - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "phpstan/rules.neon" - ] - }, - "branch-alias": { - "dev-main": "2.9-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\": "src/Composer/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "https://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" - } - ], - "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", - "homepage": "https://getcomposer.org/", - "keywords": [ - "autoload", - "dependency", - "package" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/composer/issues", - "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.9.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2026-01-29T10:40:53+00:00" - }, - { - "name": "composer/metadata-minifier", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/composer/metadata-minifier.git", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "composer/composer": "^2", - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\MetadataMinifier\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "Small utility library that handles metadata minification and expansion.", - "keywords": [ - "composer", - "compression" - ], - "support": { - "issues": "https://github.com/composer/metadata-minifier/issues", - "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-04-07T13:37:33+00:00" - }, - { - "name": "composer/pcre", - "version": "3.3.2", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "phpstan": { - "includes": [ - "extension.neon" - ] - }, - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-11-12T16:29:46+00:00" - }, { "name": "composer/semver", "version": "3.4.4", @@ -596,152 +194,6 @@ ], "time": "2025-08-20T19:15:30+00:00" }, - { - "name": "composer/spdx-licenses", - "version": "1.5.9", - "source": { - "type": "git", - "url": "https://github.com/composer/spdx-licenses.git", - "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", - "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Spdx\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "SPDX licenses list and validation library.", - "keywords": [ - "license", - "spdx", - "validator" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2025-05-12T21:07:07+00:00" - }, - { - "name": "composer/xdebug-handler", - "version": "3.0.5", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", - "shasum": "" - }, - "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" - }, { "name": "doctrine/annotations", "version": "1.14.4", @@ -1905,81 +1357,6 @@ }, "time": "2025-01-17T21:19:20+00:00" }, - { - "name": "justinrainbow/json-schema", - "version": "6.6.4", - "source": { - "type": "git", - "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "shasum": "" - }, - "require": { - "ext-json": "*", - "marc-mabe/php-enum": "^4.4", - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", - "marc-mabe/php-enum-phpstan": "^2.0", - "phpspec/prophecy": "^1.19", - "phpstan/phpstan": "^1.12", - "phpunit/phpunit": "^8.5" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "6.x-dev" - } - }, - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/jsonrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" - }, - "time": "2025-12-19T15:01:32+00:00" - }, { "name": "laminas/laminas-authentication", "version": "2.18.0", @@ -4656,83 +4033,10 @@ { "url": "https://funding.communitybridge.org/projects/laminas-project", "type": "community_bridge" - } - ], - "abandoned": true, - "time": "2023-11-24T13:56:19+00:00" - }, - { - "name": "marc-mabe/php-enum", - "version": "v4.7.2", - "source": { - "type": "git", - "url": "https://github.com/marc-mabe/php-enum.git", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", - "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", - "shasum": "" - }, - "require": { - "ext-reflection": "*", - "php": "^7.1 | ^8.0" - }, - "require-dev": { - "phpbench/phpbench": "^0.16.10 || ^1.0.4", - "phpstan/phpstan": "^1.3.1", - "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", - "vimeo/psalm": "^4.17.0 | ^5.26.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-3.x": "3.2-dev", - "dev-master": "4.7-dev" - } - }, - "autoload": { - "psr-4": { - "MabeEnum\\": "src/" - }, - "classmap": [ - "stubs/Stringable.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Marc Bennewitz", - "email": "dev@mabe.berlin", - "homepage": "https://mabe.berlin/", - "role": "Lead" - } - ], - "description": "Simple and fast implementation of enumerations with native PHP", - "homepage": "https://github.com/marc-mabe/php-enum", - "keywords": [ - "enum", - "enum-map", - "enum-set", - "enumeration", - "enumerator", - "enummap", - "enumset", - "map", - "set", - "type", - "type-hint", - "typehint" + } ], - "support": { - "issues": "https://github.com/marc-mabe/php-enum/issues", - "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" - }, - "time": "2025-09-14T11:18:39+00:00" + "abandoned": true, + "time": "2023-11-24T13:56:19+00:00" }, { "name": "ml/iri", @@ -4927,7 +4231,7 @@ "dist": { "type": "path", "url": "application/data/composer-addon-installer", - "reference": "8ec3fb6ecaed6f9d5896a549c6b9b8524c6ed3bb" + "reference": "82f262c0558d4379c2418e8cd4d5b39212ffb364" }, "require": { "composer-plugin-api": "^2.0", @@ -4945,39 +4249,7 @@ "license": [ "GPL-3.0" ], - "description": "Composer plugin to install Omeka S modules and themes into addons/modules/ and addons/themes/.", - "transport-options": { - "relative": true - } - }, - { - "name": "omeka/omeka-assets", - "version": "1.0.0", - "dist": { - "type": "path", - "url": "application/data/omeka-assets", - "reference": "c288c28e6b6894aa641f63880531f0ecb7147392" - }, - "require": { - "composer-plugin-api": "^2.0", - "php": ">=8.1" - }, - "require-dev": { - "composer/composer": "^2.0" - }, - "type": "composer-plugin", - "extra": { - "class": "Omeka\\OmekaAssets\\OmekaAssetsPlugin" - }, - "autoload": { - "psr-4": { - "Omeka\\OmekaAssets\\": "src/" - } - }, - "license": [ - "GPL-3.0-or-later" - ], - "description": "Composer plugin to download external assets for Omeka S modules and themes", + "description": "Composer plugin to install Omeka S modules and themes into composer-addons/modules/ and composer-addons/themes/.", "transport-options": { "relative": true } @@ -5005,284 +4277,53 @@ "dev-master": "1.0.x-dev" } }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/3.0.0" - }, - "time": "2021-02-03T23:26:27+00:00" - }, - { - "name": "psr/container", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" - }, - "time": "2021-11-05T16:50:12+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "psr/log", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" - }, - "time": "2021-05-03T11:20:27+00:00" - }, - { - "name": "react/promise", - "version": "v3.3.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", - "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", - "shasum": "" - }, - "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.12.28 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "description": "Common interface for caching libraries", "keywords": [ - "promise", - "promises" + "cache", + "psr", + "psr-6" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.3.0" + "source": "https://github.com/php-fig/cache/tree/3.0.0" }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2025-08-19T18:57:03+00:00" + "time": "2021-02-03T23:26:27+00:00" }, { - "name": "seld/jsonlint", - "version": "1.11.0", + "name": "psr/container", + "version": "1.1.2", "source": { "type": "git", - "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" + "url": "https://github.com/php-fig/container.git", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", "shasum": "" }, "require": { - "php": "^5.3 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13" + "php": ">=7.4.0" }, - "bin": [ - "bin/jsonlint" - ], "type": "library", "autoload": { "psr-4": { - "Seld\\JsonLint\\": "src/Seld/JsonLint/" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -5291,60 +4332,51 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "https://seld.be" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "JSON Linter", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", "keywords": [ - "json", - "linter", - "parser", - "validator" + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" ], "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.2" }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", - "type": "tidelift" - } - ], - "time": "2024-07-11T14:55:45+00:00" + "time": "2021-11-05T16:50:12+00:00" }, { - "name": "seld/phar-utils", - "version": "1.2.1", + "name": "psr/http-message", + "version": "2.0", "source": { "type": "git", - "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "php": ">=5.3" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Seld\\PharUtils\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -5353,54 +4385,51 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "PHAR file format utilities, for when PHP phars you up", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", "keywords": [ - "phar" + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" ], "support": { - "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2022-08-31T10:31:18+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { - "name": "seld/signal-handler", - "version": "2.0.2", + "name": "psr/log", + "version": "1.1.4", "source": { "type": "git", - "url": "https://github.com/Seldaek/signal-handler.git", - "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98" + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", - "reference": "04a6112e883ad76c0ada8e4a9f7520bbfdb6bb98", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", "shasum": "" }, "require": { - "php": ">=7.2.0" - }, - "require-dev": { - "phpstan/phpstan": "^1", - "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^7.5.20 || ^8.5.23", - "psr/log": "^1 || ^2 || ^3" + "php": ">=5.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { "psr-4": { - "Seld\\Signal\\": "src/" + "Psr\\Log\\": "Psr/Log/" } }, "notification-url": "https://packagist.org/downloads/", @@ -5409,24 +4438,21 @@ ], "authors": [ { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", "keywords": [ - "posix", - "sigint", - "signal", - "sigterm", - "unix" + "log", + "psr", + "psr-3" ], "support": { - "issues": "https://github.com/Seldaek/signal-handler/issues", - "source": "https://github.com/Seldaek/signal-handler/tree/2.0.2" + "source": "https://github.com/php-fig/log/tree/1.1.4" }, - "time": "2023-09-03T09:24:00+00:00" + "time": "2021-05-03T11:20:27+00:00" }, { "name": "sweetrdf/easyrdf", @@ -5727,148 +4753,15 @@ "extra": { "thanks": { "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-25T14:21:43+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v6.4.24", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", - "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.24" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-07-10T08:14:14+00:00" - }, - { - "name": "symfony/finder", - "version": "v5.4.45", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "63741784cd7b9967975eec610b256eed3ede022b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", - "reference": "63741784cd7b9967975eec610b256eed3ede022b", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } }, - "type": "library", "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "files": [ + "function.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5877,18 +4770,18 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Finds files and directories via an intuitive fluent interface", + "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.45" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -5904,7 +4797,7 @@ "type": "tidelift" } ], - "time": "2024-09-28T13:32:08+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6393,86 +5286,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-php73", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", - "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, { "name": "symfony/polyfill-php80", "version": "v1.33.0", @@ -6557,190 +5370,39 @@ ], "time": "2025-01-02T08:10:11+00:00" }, - { - "name": "symfony/polyfill-php81", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php84\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-06-24T13:30:11+00:00" - }, - { - "name": "symfony/process", - "version": "v6.4.26", + "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", - "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=7.2" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Polyfill\\Php84\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6749,18 +5411,24 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/process/tree/v6.4.26" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" }, "funding": [ { @@ -6780,7 +5448,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T09:57:09+00:00" + "time": "2025-06-24T13:30:11+00:00" }, { "name": "symfony/service-contracts", @@ -7179,6 +5847,151 @@ ], "time": "2022-12-23T10:58:28+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -8467,14 +7280,87 @@ "homepage": "https://cboden.dev/" } ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "asynchronous", - "event-loop" + "promise", + "promises" ], "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { @@ -8482,7 +7368,7 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { "name": "react/socket", @@ -9313,22 +8199,261 @@ }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "3.2-dev" } }, "autoload": { @@ -9343,14 +8468,15 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, "funding": [ { @@ -9358,32 +8484,29 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2023-02-03T06:13:03+00:00" }, { - "name": "sebastian/object-reflector", - "version": "2.0.4", + "name": "sebastian/version", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", "shasum": "" }, "require": { "php": ">=7.3" }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -9398,14 +8521,15 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" }, "funding": [ { @@ -9413,267 +8537,334 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2020-09-28T06:39:44+00:00" }, { - "name": "sebastian/recursion-context", - "version": "4.0.6", + "name": "symfony/css-selector", + "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + "url": "https://github.com/symfony/css-selector.git", + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", + "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", "shasum": "" }, "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "php": ">=8.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + "source": "https://github.com/symfony/css-selector/tree/v6.4.24" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://symfony.com/sponsor", + "type": "custom" }, { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" + "url": "https://github.com/fabpot", + "type": "github" }, { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-10T06:57:39+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { - "name": "sebastian/resource-operations", - "version": "3.0.4", + "name": "symfony/dom-crawler", + "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", "shasum": "" }, "require": { - "php": ">=7.3" + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" }, "require-dev": { - "phpunit/phpunit": "^9.0" + "symfony/css-selector": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-05T18:56:08+00:00" }, { - "name": "sebastian/type", - "version": "3.2.1", + "name": "symfony/event-dispatcher", + "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "b0cf3162020603587363f0551cd3be43958611ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", + "reference": "b0cf3162020603587363f0551cd3be43958611ff", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^5.4|^6.0|^7.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.2-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-08-13T09:41:44+00:00" }, { - "name": "sebastian/version", - "version": "3.0.2", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "3.6-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/css-selector", + "name": "symfony/filesystem", "version": "v6.4.24", "source": { "type": "git", - "url": "https://github.com/symfony/css-selector.git", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761" + "url": "https://github.com/symfony/filesystem.git", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/9b784413143701aa3c94ac1869a159a9e53e8761", - "reference": "9b784413143701aa3c94ac1869a159a9e53e8761", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", + "reference": "75ae2edb7cdcc0c53766c30b0a2512b8df574bd8", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^5.4|^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\CssSelector\\": "" + "Symfony\\Component\\Filesystem\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9688,19 +8879,15 @@ "name": "Fabien Potencier", "email": "fabien@symfony.com" }, - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Converts CSS selectors to XPath expressions", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.24" + "source": "https://github.com/symfony/filesystem/tree/v6.4.24" }, "funding": [ { @@ -9723,32 +8910,28 @@ "time": "2025-07-10T08:14:14+00:00" }, { - "name": "symfony/dom-crawler", - "version": "v6.4.25", + "name": "symfony/finder", + "version": "v5.4.45", "source": { "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" + "url": "https://github.com/symfony/finder.git", + "reference": "63741784cd7b9967975eec610b256eed3ede022b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", - "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", "shasum": "" }, "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" + "Symfony\\Component\\Finder\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9768,10 +8951,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Eases DOM navigation for HTML and XML documents", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" + "source": "https://github.com/symfony/finder/tree/v5.4.45" }, "funding": [ { @@ -9782,57 +8965,35 @@ "url": "https://github.com/fabpot", "type": "github" }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-08-05T18:56:08+00:00" + "time": "2024-09-28T13:32:08+00:00" }, { - "name": "symfony/event-dispatcher", + "name": "symfony/options-resolver", "version": "v6.4.25", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "b0cf3162020603587363f0551cd3be43958611ff" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b0cf3162020603587363f0551cd3be43958611ff", - "reference": "b0cf3162020603587363f0551cd3be43958611ff", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d28e7e2db8a73e9511df892d36445f61314bbebe", + "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/event-dispatcher-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" + "Symfony\\Component\\OptionsResolver\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9852,10 +9013,15 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "description": "Provides an improved replacement for the array_replace PHP function", "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.25" + "source": "https://github.com/symfony/options-resolver/tree/v6.4.25" }, "funding": [ { @@ -9875,40 +9041,42 @@ "type": "tidelift" } ], - "time": "2025-08-13T09:41:44+00:00" + "time": "2025-08-04T17:06:28+00:00" }, { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "name": "symfony/polyfill-php81", + "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" + "php": ">=7.2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -9924,18 +9092,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to dispatching event", + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "compatibility", + "polyfill", + "portable", + "shim" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" }, "funding": [ { @@ -9946,35 +9112,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/options-resolver", - "version": "v6.4.25", + "name": "symfony/process", + "version": "v6.4.26", "source": { "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe" + "url": "https://github.com/symfony/process.git", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d28e7e2db8a73e9511df892d36445f61314bbebe", - "reference": "d28e7e2db8a73e9511df892d36445f61314bbebe", + "url": "https://api.github.com/repos/symfony/process/zipball/48bad913268c8cafabbf7034b39c8bb24fbc5ab8", + "reference": "48bad913268c8cafabbf7034b39c8bb24fbc5ab8", "shasum": "" }, "require": { - "php": ">=8.1", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.1" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" + "Symfony\\Component\\Process\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -9994,15 +9163,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an improved replacement for the array_replace PHP function", + "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v6.4.25" + "source": "https://github.com/symfony/process/tree/v6.4.26" }, "funding": [ { @@ -10022,7 +9186,7 @@ "type": "tidelift" } ], - "time": "2025-08-04T17:06:28+00:00" + "time": "2025-09-11T09:57:09+00:00" }, { "name": "symfony/stopwatch", From 64ca11eb85f26b0e1449c78e4b28da91d4bd45f8 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 22/26] Removed management of specific keys for theme. --- application/src/Module/InfoReader.php | 2 - .../test/OmekaTest/Module/InfoReaderTest.php | 92 ------------------- composer-addons/README.md | 5 - 3 files changed, 99 deletions(-) diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 1af5443fe2..cf9ecaab52 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -47,8 +47,6 @@ class InfoReader protected $extraToIniMap = [ 'label' => 'name', 'configurable' => 'configurable', - 'has-translations' => 'has_translations', - 'omeka-helpers' => 'helpers', ]; /** diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php index 77b0bef895..fe5422d7f9 100644 --- a/application/test/OmekaTest/Module/InfoReaderTest.php +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -251,98 +251,6 @@ public function testReadComposerJsonWithConfigurable() $this->assertTrue($info['configurable']); } - public function testReadComposerJsonWithHasTranslations() - { - $composer = [ - 'name' => 'vendor/omeka-s-theme-test', - 'extra' => [ - 'label' => 'Test Theme', - 'has-translations' => true, - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'theme'); - - $this->assertTrue($info['has_translations']); - } - - public function testReadComposerJsonWithOmekaHelpers() - { - $composer = [ - 'name' => 'vendor/omeka-s-theme-test', - 'extra' => [ - 'label' => 'Test Theme', - 'omeka-helpers' => ['ThemeFunctions', 'Breadcrumbs'], - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'theme'); - - $this->assertIsArray($info['helpers']); - $this->assertEquals(['ThemeFunctions', 'Breadcrumbs'], $info['helpers']); - } - - public function testReadThemeIniWithHasTranslations() - { - $ini = <<<'INI' - [info] - name = "Test Theme" - version = "1.0.0" - has_translations = true - INI; - file_put_contents($this->testPath . '/config/theme.ini', $ini); - - $info = $this->infoReader->read($this->testPath, 'theme'); - - $this->assertTrue((bool) $info['has_translations']); - } - - public function testReadThemeIniWithHelpers() - { - $ini = <<<'INI' - [info] - name = "Test Theme" - version = "1.0.0" - helpers[] = ThemeFunctions - helpers[] = Breadcrumbs - INI; - file_put_contents($this->testPath . '/config/theme.ini', $ini); - - $info = $this->infoReader->read($this->testPath, 'theme'); - - $this->assertIsArray($info['helpers']); - $this->assertEquals(['ThemeFunctions', 'Breadcrumbs'], $info['helpers']); - } - - public function testComposerOmekaHelpersOverrideIniHelpers() - { - // Create theme.ini with helpers. - $ini = <<<'INI' - [info] - name = "Test Theme" - version = "1.0.0" - helpers[] = IniHelper - INI; - file_put_contents($this->testPath . '/config/theme.ini', $ini); - - // Create composer.json with different helpers. - $composer = [ - 'name' => 'vendor/omeka-s-theme-test', - 'extra' => [ - 'label' => 'Test Theme', - 'omeka-helpers' => ['ComposerHelper'], - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'theme'); - - // Composer omeka-helpers should take precedence. - $this->assertEquals(['ComposerHelper'], $info['helpers']); - } - // ------------------------------------------------------------------------- // Tests: Both sources (composer.json takes precedence) // ------------------------------------------------------------------------- diff --git a/composer-addons/README.md b/composer-addons/README.md index e5d42c4b05..5b135bcb11 100644 --- a/composer-addons/README.md +++ b/composer-addons/README.md @@ -51,11 +51,6 @@ The file composer.json supports optional specific keys under key `extra`: - `label`: display label when different from project name. - `configurable`: boolean to specify if the module is configurable. -Specific keys for themes: - -- `has-translations`: boolean to specify if the theme has its own translations. -- `omeka-helpers`: array of custom view helper class names to load. - If an extra key is not available, a check is done for an equivalent in file `config/module.ini` or `config/theme.ini`, if present, else a default value is set. From 43a54b5e45cd04d0167d4aecb6574a24213259a5 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 23/26] Moved key "configurable" from ini to module.config.php. --- application/src/Module/InfoReader.php | 8 +- .../src/Service/ModuleManagerFactory.php | 42 +++++++++++ .../test/OmekaTest/Module/InfoReaderTest.php | 25 ++----- .../Service/ModuleManagerFactoryTest.php | 74 +++++++++++++++++-- composer-addons/README.md | 18 ++++- 5 files changed, 134 insertions(+), 33 deletions(-) diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index cf9ecaab52..44fc915407 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -14,7 +14,7 @@ * * Note: installed.json is used instead of installed.php because installed.php * only contains minimal data (version, type, install_path) without the `extra` - * keys needed for add-on metadata (label, configurable, etc.). + * keys needed for add-on metadata (label, etc.). */ class InfoReader { @@ -46,7 +46,6 @@ class InfoReader */ protected $extraToIniMap = [ 'label' => 'name', - 'configurable' => 'configurable', ]; /** @@ -235,9 +234,6 @@ protected function applyDefaults(array $info, string $path, ?array $composerJson } } - // Default configurable is false. - $info['configurable'] = !empty($info['configurable']); - return $info; } @@ -506,8 +502,6 @@ protected function buildInfoFromPackage(array $package): array $info['version'] = ltrim($version, 'vV'); } - $info['configurable'] = !empty($info['configurable']); - // Clean up description: remove common prefixes like "Module for Omeka S:". if (!empty($info['description'])) { $info['description'] = preg_replace( diff --git a/application/src/Service/ModuleManagerFactory.php b/application/src/Service/ModuleManagerFactory.php index 1829c7f54e..a562e4939d 100644 --- a/application/src/Service/ModuleManagerFactory.php +++ b/application/src/Service/ModuleManagerFactory.php @@ -84,6 +84,9 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar continue; } + // Check configurable from module.config.php (priority) or module.ini (fallback). + $info['configurable'] = $this->isModuleConfigurable($dir->getPathname(), $info); + $module->setIni($info); // Module directory must contain Module.php. @@ -177,4 +180,43 @@ public function __invoke(ContainerInterface $serviceLocator, $requestedName, ?ar return $manager; } + + /** + * Determine if a module is configurable. + * + * Priority: + * 1. module.config.php ['module_config']['configurable'] + * 2. module.ini 'configurable' (fallback, already in $info) + * + * Note: Some module.config.php files use self::CONSTANT which requires the + * module class context. We use error handling to gracefully skip those. + * + * @param string $modulePath Path to the module directory + * @param array $info Module info from InfoReader + * @return bool + */ + protected function isModuleConfigurable(string $modulePath, array $info): bool + { + // Priority 1: Check module.config.php. + $configFile = $modulePath . '/config/module.config.php'; + if (is_file($configFile) && is_readable($configFile)) { + // Convert errors to exceptions to catch undefined constants, etc. + set_error_handler(function ($severity, $message) { + throw new \ErrorException($message, 0, $severity); + }); + try { + $config = include $configFile; + if (is_array($config) && isset($config['module_config']['configurable'])) { + restore_error_handler(); + return (bool) $config['module_config']['configurable']; + } + } catch (\Throwable $e) { + // Config file uses module-specific constants or has errors, skip. + } + restore_error_handler(); + } + + // Priority 2: Fallback to module.ini (already read into $info). + return !empty($info['configurable']); + } } diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php index fe5422d7f9..775868c9d1 100644 --- a/application/test/OmekaTest/Module/InfoReaderTest.php +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -120,7 +120,8 @@ public function testReadModuleIniWithAllFields() $this->assertEquals('https://example.com/author', $info['author_link']); $this->assertEquals('https://example.com/support', $info['support_link']); $this->assertEquals('^4.0', $info['omeka_version_constraint']); - $this->assertTrue($info['configurable']); + // configurable from ini is a string, ModuleManagerFactory converts to bool. + $this->assertNotEmpty($info['configurable']); } // ------------------------------------------------------------------------- @@ -235,22 +236,6 @@ public function testReadComposerJsonForTheme() $this->assertEquals('https://example.com/theme', $info['theme_link']); } - public function testReadComposerJsonWithConfigurable() - { - $composer = [ - 'name' => 'vendor/omeka-s-module-test', - 'extra' => [ - 'label' => 'Test', - 'configurable' => true, - ], - ]; - file_put_contents($this->testPath . '/composer.json', json_encode($composer)); - - $info = $this->infoReader->read($this->testPath, 'module'); - - $this->assertTrue($info['configurable']); - } - // ------------------------------------------------------------------------- // Tests: Both sources (composer.json takes precedence) // ------------------------------------------------------------------------- @@ -346,8 +331,10 @@ public function testDefaultVersionWhenMissing() $this->assertEquals('1.0.0', $info['version']); } - public function testDefaultConfigurableIsFalse() + public function testConfigurableNotSetByDefault() { + // configurable is now set by ModuleManagerFactory, not InfoReader. + // InfoReader only reads it from module.ini if present. $ini = <<<'INI' [info] name = "Test Module" @@ -357,7 +344,7 @@ public function testDefaultConfigurableIsFalse() $info = $this->infoReader->read($this->testPath, 'module'); - $this->assertFalse($info['configurable']); + $this->assertArrayNotHasKey('configurable', $info); } // ------------------------------------------------------------------------- diff --git a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php index 4532e15730..44da0a4aa2 100644 --- a/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -482,25 +482,50 @@ public function testInfoReaderFallsBackToIniWhenComposerInvalid() // ------------------------------------------------------------------------- /** - * Test configurable flag from composer.json extra. + * Test configurable flag from module.config.php. */ - public function testConfigurableFlagFromComposer() + public function testConfigurableFlagFromModuleConfig() { $modulePath = $this->createModuleWithComposer( $this->testModulesPath, 'ConfigurableModule', + '1.0.0' + ); + + // Create module.config.php with configurable = true. + $config = " [\n 'configurable' => true,\n ],\n];\n"; + mkdir($modulePath . '/config', 0755, true); + file_put_contents($modulePath . '/config/module.config.php', $config); + + $factory = new ModuleManagerFactory(); + $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, []]); + + $this->assertTrue($isConfigurable); + } + + /** + * Test configurable flag fallback to module.ini. + */ + public function testConfigurableFallbackToModuleIni() + { + $modulePath = $this->createModuleWithIni( + $this->testModulesPath, + 'ConfigurableIniModule', '1.0.0', - ['configurable' => true] + ['configurable' => 'true'] ); $infoReader = new InfoReader(); $info = $infoReader->read($modulePath, 'module'); - $this->assertTrue($info['configurable']); + $factory = new ModuleManagerFactory(); + $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, $info]); + + $this->assertTrue($isConfigurable); } /** - * Test configurable defaults to false. + * Test configurable defaults to false when not set. */ public function testConfigurableDefaultsFalse() { @@ -510,10 +535,47 @@ public function testConfigurableDefaultsFalse() '1.0.0' ); + $factory = new ModuleManagerFactory(); + $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, []]); + + $this->assertFalse($isConfigurable); + } + + /** + * Test module.config.php takes precedence over module.ini for configurable. + */ + public function testConfigurableModuleConfigTakesPrecedence() + { + $modulePath = $this->createModuleWithIni( + $this->testModulesPath, + 'PrecedenceModule', + '1.0.0', + ['configurable' => 'true'] + ); + + // Create module.config.php with configurable = false. + $config = " [\n 'configurable' => false,\n ],\n];\n"; + file_put_contents($modulePath . '/config/module.config.php', $config); + $infoReader = new InfoReader(); $info = $infoReader->read($modulePath, 'module'); - $this->assertFalse($info['configurable']); + $factory = new ModuleManagerFactory(); + $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, $info]); + + // module.config.php should take precedence over module.ini. + $this->assertFalse($isConfigurable); + } + + /** + * Helper method to invoke protected/private methods. + */ + protected function invokeMethod($object, string $methodName, array $parameters = []) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invokeArgs($object, $parameters); } /** diff --git a/composer-addons/README.md b/composer-addons/README.md index 5b135bcb11..535ebc4bfe 100644 --- a/composer-addons/README.md +++ b/composer-addons/README.md @@ -49,13 +49,29 @@ The file composer.json supports optional specific keys under key `extra`: - `installer-name`: directory to use when different from project name. - `label`: display label when different from project name. -- `configurable`: boolean to specify if the module is configurable. If an extra key is not available, a check is done for an equivalent in file `config/module.ini` or `config/theme.ini`, if present, else a default value is set. +Configurable modules +-------------------- + +To declare a module as configurable, add the key in `config/module.config.php`: + +```php +return [ + 'module_config' => [ + 'configurable' => true, + ], + // ... other config +]; +``` + +This takes precedence over `config/module.ini` (fallback for legacy modules). + + External assets --------------- From f89616f46369843dba54b94285b9433eaf244de0 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 24/26] Removed specific code written for a smooth evolution with module Common. --- .../src/AddonInstallerPlugin.php | 126 +----------------- .../test/OmekaTest/Module/AutoloaderTest.php | 97 +++++++++++++- bootstrap.php | 16 --- 3 files changed, 93 insertions(+), 146 deletions(-) diff --git a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index ee3a1263a2..5899d83e6d 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -3,30 +3,18 @@ namespace Omeka\Composer; use Composer\Composer; -use Composer\EventDispatcher\EventSubscriberInterface; -use Composer\Installer\PackageEvent; -use Composer\Installer\PackageEvents; use Composer\IO\IOInterface; use Composer\Plugin\PluginInterface; /** * Composer plugin for Omeka S add-on installation. * - * Registers the AddonInstaller and handles Common module symlink. + * Registers the AddonInstaller for modules and themes. */ -class AddonInstallerPlugin implements PluginInterface, EventSubscriberInterface +class AddonInstallerPlugin implements PluginInterface { - /** @var Composer */ - protected $composer; - - /** @var IOInterface */ - protected $io; - public function activate(Composer $composer, IOInterface $io) { - $this->composer = $composer; - $this->io = $io; - $installer = new AddonInstaller($io, $composer); $composer->getInstallationManager()->addInstaller($installer); } @@ -38,114 +26,4 @@ public function deactivate(Composer $composer, IOInterface $io) public function uninstall(Composer $composer, IOInterface $io) { } - - public static function getSubscribedEvents() - { - return [ - PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall', - PackageEvents::POST_PACKAGE_UPDATE => 'onPostPackageUpdate', - PackageEvents::PRE_PACKAGE_UNINSTALL => 'onPrePackageUninstall', - ]; - } - - /** - * Handle pre-uninstall for packages. - */ - public function onPrePackageUninstall(PackageEvent $event) - { - $package = $event->getOperation()->getPackage(); - $this->removeCommonModuleSymlink($package); - } - - /** - * Remove Common module symlink when uninstalling. - */ - protected function removeCommonModuleSymlink($package) - { - if ($package->getName() !== 'daniel-km/omeka-s-module-common') { - return; - } - - $localPath = 'modules/Common'; - - if (is_link($localPath)) { - if (@unlink($localPath)) { - $this->io->write(sprintf( - 'Removed symlink %s', - $localPath - )); - } - } - } - - /** - * Handle post-install for packages. - */ - public function onPostPackageInstall(PackageEvent $event) - { - $package = $event->getOperation()->getPackage(); - $this->handleCommonModuleSymlink($package); - } - - /** - * Handle post-update for packages. - */ - public function onPostPackageUpdate(PackageEvent $event) - { - $package = $event->getOperation()->getTargetPackage(); - $this->handleCommonModuleSymlink($package); - } - - /** - * Create symlink for Common module if not present in modules/. - * - * Common module is a special case: many modules depend on its root-level - * files (TraitModule.php, etc.) via require_once with file paths like: - * require_once dirname(__DIR__) . '/Common/TraitModule.php'; - * - * When Common is installed via Composer to composer-addons/modules/Common/, this - * path doesn't work. A symlink modules/Common -> composer-addons/modules/Common - * ensures backward compatibility. - */ - protected function handleCommonModuleSymlink($package) - { - // Only handle Common module - if ($package->getName() !== 'daniel-km/omeka-s-module-common') { - return; - } - - $localPath = 'modules/Common'; - - // Don't create symlink if a real directory exists (local override) - if (is_dir($localPath) && !is_link($localPath)) { - return; - } - - $installPath = $this->composer->getInstallationManager()->getInstallPath($package); - $relativePath = '../' . $installPath; - - // Update existing symlink if target changed - if (is_link($localPath)) { - $currentTarget = readlink($localPath); - if ($currentTarget === $relativePath) { - return; - } - unlink($localPath); - } - - // Create symlink - if (@symlink($relativePath, $localPath)) { - $this->io->write(sprintf( - 'Created symlink %s -> %s for backward compatibility', - $localPath, - $relativePath - )); - } else { - $this->io->writeError(sprintf( - 'Could not create symlink %s -> %s', - $localPath, - $relativePath - )); - } - } } diff --git a/application/test/OmekaTest/Module/AutoloaderTest.php b/application/test/OmekaTest/Module/AutoloaderTest.php index 0d45005150..c3b29ce65e 100644 --- a/application/test/OmekaTest/Module/AutoloaderTest.php +++ b/application/test/OmekaTest/Module/AutoloaderTest.php @@ -231,12 +231,97 @@ public function testAutoloaderDoesNotInterveneForAddonOnlyModule() } /** - * Test the Common module special case for root-level classes. + * Test partial override: only some classes exist in local module. * - * Common module has classes like TraitModule in the root directory (not src/). - * The autoloader has special handling for these. + * When a module exists in both locations but the local version only contains + * some classes (partial override), the autoloader should: + * - Load existing classes from local + * - Return false for missing classes, allowing Composer to load from addon */ - public function testCommonModuleRootLevelClasses() + public function testPartialOverrideAllowsFallbackToAddon() + { + // Create full module in composer-addons/modules/ with two classes. + $this->createTestModule($this->addonModulePath, 'addon'); + $this->createAdditionalClass($this->addonModulePath, 'AnotherService', 'addon'); + + // Create partial override in modules/ with only TestService (not AnotherService). + $this->createTestModule($this->localModulePath, 'local'); + // Note: AnotherService is NOT created in local. + + // Verify both directories exist (autoloader will intervene). + $this->assertTrue(is_dir($this->localModulePath)); + $this->assertTrue(is_dir($this->addonModulePath)); + + // Verify file existence. + $localTestService = $this->localModulePath . '/src/TestService.php'; + $localAnotherService = $this->localModulePath . '/src/AnotherService.php'; + $addonAnotherService = $this->addonModulePath . '/src/AnotherService.php'; + + $this->assertTrue(file_exists($localTestService), 'TestService should exist in local'); + $this->assertFalse(file_exists($localAnotherService), 'AnotherService should NOT exist in local'); + $this->assertTrue(file_exists($addonAnotherService), 'AnotherService should exist in addon'); + + // Test the autoloader logic directly (simulating what bootstrap.php does). + // For TestService: file exists in local -> would be loaded from local. + // For AnotherService: file doesn't exist in local -> autoloader returns false. + $testServiceClass = $this->testModuleName . '\\TestService'; + $anotherServiceClass = $this->testModuleName . '\\AnotherService'; + + // Simulate autoloader logic for AnotherService. + $moduleNamespace = $this->testModuleName; + $localModule = OMEKA_PATH . '/modules/' . $moduleNamespace; + $addonModule = OMEKA_PATH . '/composer-addons/modules/' . $moduleNamespace; + + // Autoloader would check this file for AnotherService. + $relativePath = str_replace('\\', '/', 'AnotherService'); + $localFile = $localModule . '/src/' . $relativePath . '.php'; + + // The autoloader returns false when file doesn't exist, allowing fallback. + $this->assertFalse(file_exists($localFile), + 'AnotherService should not exist in local, so autoloader returns false and Composer can load from addon'); + + // For TestService, verify it loads from local. + if (!class_exists($testServiceClass, false)) { + $this->assertTrue(class_exists($testServiceClass, true), 'TestService should be autoloadable'); + $reflection = new \ReflectionClass($testServiceClass); + $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(), + 'TestService should be loaded from local modules/'); + $this->assertEquals('local', $testServiceClass::SOURCE, + 'TestService SOURCE should be "local"'); + } + } + + /** + * Create an additional service class in a module. + */ + protected function createAdditionalClass($basePath, $className, $source) + { + $servicePhp = <<testModuleName}; + +class {$className} +{ + const SOURCE = '$source'; + + public function getSource(): string + { + return self::SOURCE; + } +} +PHP; + file_put_contents($basePath . '/src/' . $className . '.php', $servicePhp); + } + + /** + * Test PSR-4 priority for Common module classes. + * + * Verifies that Common module classes are loaded from modules/Common/src/ + * (local) when the module exists in both modules/ and composer-addons/modules/. + * This follows standard PSR-4 autoloading - no special handling required. + */ + public function testCommonModulePsr4Priority() { // This test uses the real Common module if it exists in both locations. $localCommon = OMEKA_PATH . '/modules/Common'; @@ -246,13 +331,13 @@ public function testCommonModuleRootLevelClasses() $this->markTestSkipped('Common module not present in both locations.'); } - // Test that TraitModule can be loaded. + // Test that TraitModule can be loaded (PSR-4: Common\TraitModule -> src/TraitModule.php). $this->assertTrue( trait_exists('Common\\TraitModule', true), 'Common\\TraitModule should be loadable' ); - // Verify it's loaded from local. + // Verify it's loaded from local modules/, not composer-addons/. $reflection = new \ReflectionClass('Common\\TraitModule'); $this->assertStringContainsString('/modules/Common/', $reflection->getFileName(), 'Common\\TraitModule should be loaded from modules/Common/'); diff --git a/bootstrap.php b/bootstrap.php index bf37dfea60..ecb357a2c0 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -30,22 +30,6 @@ return false; // No conflict, let Composer handle it } - // Special case: Common module has root-level classes - if ($moduleNamespace === 'Common') { - static $commonRootClasses = [ - 'Common\\AbstractModule' => 'AbstractModule.php', - 'Common\\ManageModuleAndResources' => 'ManageModuleAndResources.php', - 'Common\\TraitModule' => 'TraitModule.php', - ]; - if (isset($commonRootClasses[$class])) { - $file = $localModule . '/' . $commonRootClasses[$class]; - if (file_exists($file)) { - require_once $file; - return true; - } - } - } - // PSR-4: ModuleName\Foo\Bar -> modules/ModuleName/src/Foo/Bar.php $relativePath = str_replace('\\', '/', substr($class, $pos + 1)); $file = $localModule . '/src/' . $relativePath . '.php'; From 51848c12aa0db89fe11893be431aab249240cf09 Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Wed, 4 Feb 2026 00:00:00 +0000 Subject: [PATCH 25/26] Replace key "provide" by official "branch-alias" in composer.json. --- .../modules/TestAddonBasic/composer.json | 2 +- .../modules/TestAddonDependency/composer.json | 2 +- .../modules/TestAddonOverride/composer.json | 2 +- .../themes/test-override/composer.json | 2 +- composer-addons/README.md | 13 ++++++------- composer.json | 6 ++++-- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json index 5670ecc783..7a13a3a258 100644 --- a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/composer.json @@ -15,6 +15,6 @@ "label": "Test Addon Basic" }, "require": { - "omeka/omeka-s-core": "^4.0" + "omeka/omeka-s": "^4.0" } } diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json index f63275f368..ca0bc1d992 100644 --- a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonDependency/composer.json @@ -15,7 +15,7 @@ "label": "Test Addon Dependency" }, "require": { - "omeka/omeka-s-core": "^4.0", + "omeka/omeka-s": "^4.0", "laminas/laminas-json": "^3.0" } } diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json index dd4a573ebf..bd2b5ae734 100644 --- a/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonOverride/composer.json @@ -15,6 +15,6 @@ "label": "Test Addon Override (Composer version)" }, "require": { - "omeka/omeka-s-core": "^4.0" + "omeka/omeka-s": "^4.0" } } diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json index 6f0b089e06..31d51842d1 100644 --- a/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/composer.json @@ -15,7 +15,7 @@ "test" ], "require": { - "omeka/omeka-s-core": "^4.2" + "omeka/omeka-s": "^4.2" }, "extra": { "installer-name": "test-override", diff --git a/composer-addons/README.md b/composer-addons/README.md index 535ebc4bfe..049c5790a7 100644 --- a/composer-addons/README.md +++ b/composer-addons/README.md @@ -22,21 +22,20 @@ https://packagist.org, it still can be managed via composer via the key Version compatibility --------------------- -Add-ons can require `omeka/omeka-s-core` to declare compatibility with a -specific Omeka version: +Add-ons can require `omeka/omeka-s` to declare compatibility with a specific +Omeka version: ```json { "require": { - "omeka/omeka-s-core": "^4.0" + "omeka/omeka-s": "^4.0" } } ``` -Note: the requirement must not be `omeka/omeka-s`, because `omeka/omeka-s` is -defined as a "project" in the main composer.json. Furthermore, Omeka S uses -composer `provide` mechanism to satisfy this dependency. By this way, the -version constraint is automatically checked at install time. +Omeka S uses Composer's `branch-alias` mechanism to ensure version constraints +work on both tagged releases and development branches. The version constraint +is automatically checked at install time. For manual add-ons (without composer), use `omeka_version_constraint` in `config/module.ini` or `config/theme.ini`. diff --git a/composer.json b/composer.json index 30419bc499..2522a7b6b2 100644 --- a/composer.json +++ b/composer.json @@ -3,8 +3,10 @@ "type": "project", "description": "The Omeka S collections management system. A local network of independently curated exhibits sharing a collaboratively built pool of items and their metadata.", "license": "GPL-3.0", - "provide": { - "omeka/omeka-s-core": "self.version" + "extra": { + "branch-alias": { + "dev-develop": "4.3.x-dev" + } }, "require": { "php": ">=8.1", From 08437af59a633f0b23d6fe3264143b5e40d0647b Mon Sep 17 00:00:00 2001 From: Daniel Berthereau Date: Mon, 9 Feb 2026 00:00:00 +0000 Subject: [PATCH 26/26] Refactored InfoReader. --- application/src/Module/InfoReader.php | 208 ++++++++++++-------------- 1 file changed, 92 insertions(+), 116 deletions(-) diff --git a/application/src/Module/InfoReader.php b/application/src/Module/InfoReader.php index 44fc915407..38df03902b 100644 --- a/application/src/Module/InfoReader.php +++ b/application/src/Module/InfoReader.php @@ -18,6 +18,31 @@ */ class InfoReader { + /** + * Generic keywords to filter out from tags. + */ + protected const GENERIC_KEYWORDS = [ + 'omeka', + 'omeka s', + 'omeka-s', + 'omeka s module', + 'omeka module', + 'module', + 'omeka s theme', + 'omeka theme', + 'theme', + ]; + + /** + * Regex patterns to remove common prefixes from project names. + */ + protected const PREFIX_PATTERN = '/^(omeka-?s?-?)?(module-|theme-)?/i'; + + /** + * Regex patterns to remove common suffixes from project names. + */ + protected const SUFFIX_PATTERN = '/(-module|-theme)?(-omeka-?s?)?$/i'; + /** * Cache of installed.json data, indexed by add-on directory name. * @@ -131,57 +156,58 @@ protected function readIniFile(string $path, string $type): ?array */ protected function merge(?array $composerJson, ?array $iniInfo, string $path): array { - $info = []; - // Start with ini info as base. - if ($iniInfo) { - $info = $iniInfo; - } + $info = $iniInfo ?: []; - // If no composer.json, return ini info with defaults + // If no composer.json, return ini info with defaults. if (!$composerJson) { return $this->applyDefaults($info, $path); } + // Map package data to info, preserving existing ini values for optional + // fields. + $info = $this->mapPackageToInfo($composerJson, $info); + + return $this->applyDefaults($info, $path, $composerJson); + } + + /** + * Map composer package data to info array format. + * + * @param array $package The composer.json or installed.json package data + * @param array $existingInfo Optional existing info to preserve for fields + * not in package. + */ + protected function mapPackageToInfo(array $package, array $existingInfo = []): array + { + $info = $existingInfo; + $extra = $package['extra'] ?? []; + // Map standard composer.json fields. foreach ($this->composerToIniMap as $composerKey => $iniKey) { - if (isset($composerJson[$composerKey]) && $composerJson[$composerKey] !== '') { - $info[$iniKey] = $composerJson[$composerKey]; + if (isset($package[$composerKey]) && $package[$composerKey] !== '') { + $info[$iniKey] = $package[$composerKey]; } } // Map extra fields. - $extra = $composerJson['extra'] ?? []; foreach ($this->extraToIniMap as $extraKey => $iniKey) { if (isset($extra[$extraKey])) { $info[$iniKey] = $extra[$extraKey]; } } - // Map keywords to tags. - if (isset($composerJson['keywords']) && is_array($composerJson['keywords'])) { - // Filter out generic keywords. - $keywords = array_filter($composerJson['keywords'], function ($keyword) { - return !in_array(strtolower($keyword), [ - 'omeka', - 'omeka s', - 'omeka-s', - 'omeka s module', - 'omeka module', - 'module', - 'omeka s theme', - 'omeka theme', - 'theme', - ]); - }); + // Map keywords to tags, filtering out generic keywords. + if (isset($package['keywords']) && is_array($package['keywords'])) { + $keywords = $this->filterGenericKeywords($package['keywords']); if (count($keywords)) { $info['tags'] = implode(', ', $keywords); } } - // Map authors. - if (isset($composerJson['authors']) && is_array($composerJson['authors']) && count($composerJson['authors'])) { - $firstAuthor = $composerJson['authors'][0]; + // Map first author. + if (isset($package['authors'][0])) { + $firstAuthor = $package['authors'][0]; if (isset($firstAuthor['name']) && !isset($info['author'])) { $info['author'] = $firstAuthor['name']; } @@ -190,19 +216,27 @@ protected function merge(?array $composerJson, ?array $iniInfo, string $path): a } } - // Map support. - if (isset($composerJson['support']) && is_array($composerJson['support'])) { - if (isset($composerJson['support']['issues']) && !isset($info['support_link'])) { - $info['support_link'] = $composerJson['support']['issues']; - } + // Map support issues link. + if (isset($package['support']['issues']) && !isset($info['support_link'])) { + $info['support_link'] = $package['support']['issues']; } - // Specific: theme_link for themes. - if (isset($composerJson['homepage']) && !isset($info['theme_link'])) { - $info['theme_link'] = $composerJson['homepage']; + // theme_link uses the same homepage as module_link. + if (isset($package['homepage']) && !isset($info['theme_link'])) { + $info['theme_link'] = $package['homepage']; } - return $this->applyDefaults($info, $path, $composerJson); + return $info; + } + + /** + * Filter out generic keywords that don't add value as tags. + */ + protected function filterGenericKeywords(array $keywords): array + { + return array_filter($keywords, function ($keyword) { + return !in_array(strtolower($keyword), self::GENERIC_KEYWORDS); + }); } /** @@ -308,18 +342,7 @@ public function getInstallerName(string $path): ?string */ public function projectNameToLabel(string $projectName): string { - // Extract composer project name. - $parts = explode('/', $projectName); - $project = end($parts); - - // Remove common prefixes and suffixes. - $project = preg_replace('/^(omeka-?s?-?)?(module-|theme-)?/i', '', $project); - $project = preg_replace('/(-module|-theme)?(-omeka-?s?)?$/i', '', $project); - - // Convert kebab-case to Title Case. - $words = explode('-', $project); - $words = array_map('ucfirst', $words); - + $words = $this->projectNameToWords($projectName); return implode(' ', $words); } @@ -332,19 +355,28 @@ public function projectNameToLabel(string $projectName): string */ public function projectNameToDirectory(string $projectName): string { - // Extract composer project name. + $words = $this->projectNameToWords($projectName); + return implode('', $words); + } + + /** + * Parse project name into clean words, removing omeka/module/theme noise. + * + * @return string[] Array of capitalized words + */ + protected function projectNameToWords(string $projectName): array + { + // Extract project name after vendor/. $parts = explode('/', $projectName); $project = end($parts); // Remove common prefixes and suffixes. - $project = preg_replace('/^(omeka-?s?-?)?(module-|theme-)?/i', '', $project); - $project = preg_replace('/(-module|-theme)?(-omeka-?s?)?$/i', '', $project); + $project = preg_replace(self::PREFIX_PATTERN, '', $project); + $project = preg_replace(self::SUFFIX_PATTERN, '', $project); - // Convert kebab-case to PascalCase. + // Split kebab-case and capitalize each word. $words = explode('-', $project); - $words = array_map('ucfirst', $words); - - return implode('', $words); + return array_map('ucfirst', $words); } /** @@ -429,74 +461,18 @@ public function isComposerInstalled(string $addonId, string $type = 'module'): b } /** - * Build info array from a Composer package entry. + * Build info array from a Composer package entry in installed.json. */ protected function buildInfoFromPackage(array $package): array { - $extra = $package['extra'] ?? []; - $info = []; - - // Map standard composer.json fields. - foreach ($this->composerToIniMap as $composerKey => $iniKey) { - if (isset($package[$composerKey]) && $package[$composerKey] !== '') { - $info[$iniKey] = $package[$composerKey]; - } - } - - // Map extra fields. - foreach ($this->extraToIniMap as $extraKey => $iniKey) { - if (isset($extra[$extraKey])) { - $info[$iniKey] = $extra[$extraKey]; - } - } - - // Map keywords to tags. - if (isset($package['keywords']) && is_array($package['keywords'])) { - $keywords = array_filter($package['keywords'], function ($keyword) { - return !in_array(strtolower($keyword), [ - 'omeka', - 'omeka s', - 'omeka-s', - 'omeka s module', - 'omeka module', - 'module', - 'omeka s theme', - 'omeka theme', - 'theme', - ]); - }); - if (count($keywords)) { - $info['tags'] = implode(', ', $keywords); - } - } - - // Map authors. - if (isset($package['authors'][0])) { - $firstAuthor = $package['authors'][0]; - if (isset($firstAuthor['name'])) { - $info['author'] = $firstAuthor['name']; - } - if (isset($firstAuthor['homepage'])) { - $info['author_link'] = $firstAuthor['homepage']; - } - } - - // Map support. - if (isset($package['support']['issues'])) { - $info['support_link'] = $package['support']['issues']; - } - - // Specific: theme_link for themes. - if (isset($package['homepage'])) { - $info['theme_link'] = $package['homepage']; - } + $info = $this->mapPackageToInfo($package); - // Apply defaults. + // Default name from project name. if (empty($info['name'])) { $info['name'] = $this->projectNameToLabel($package['name'] ?? ''); } - // Version from package, with 'v' prefix removed. + // Default version, with 'v' prefix removed. if (empty($info['version'])) { $version = $package['version'] ?? '1.0.0'; $info['version'] = ltrim($version, 'vV');