diff --git a/.gitignore b/.gitignore index 70316a2746..1d18db58f2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ /files/ /modules/ /themes/ +/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 683c2201c5..0177b6ff4c 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -50,6 +50,7 @@ ->exclude('application/data/doctrine-proxies') ->exclude('application/data/media-types') ->exclude('application/data/overrides') + ->exclude('composer-addons') ->exclude('config') ->exclude('files') ->exclude('modules') diff --git a/application/config/application.config.php b/application/config/application.config.php index 1cdd31a32c..3f863cd936 100644 --- a/application/config/application.config.php +++ b/application/config/application.config.php @@ -32,6 +32,7 @@ 'module_paths' => [ 'Omeka' => OMEKA_PATH . '/application', OMEKA_PATH . '/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 d3f7ccc9e6..89d5b81024 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 composer-addons/modules/ and composer-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..de8e9da834 100644 --- a/application/data/composer-addon-installer/src/AddonInstaller.php +++ b/application/data/composer-addon-installer/src/AddonInstaller.php @@ -4,17 +4,38 @@ use Composer\Package\PackageInterface; use Composer\Installer\LibraryInstaller; +/** + * Composer installer for Omeka S modules and themes. + * + * Installs packages to composer-addons/modules/ or composer-addons/themes/ based on type. + * Name transformations align with composer/installers OmekaSInstaller. + * + * Supports extra options: + * - installer-name: Explicit install name (overrides auto-detection) + */ 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) + 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,28 +43,85 @@ 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('Add-on package names must contain a slash'); // @translate } - $addonName = substr($packageName, $slashPos + 1); - return $addonName; + $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); + } + + 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; } - public function getInstallPath(PackageInterface $package) + /** + * 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; + } + + 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 'composer-addons/modules/' . $addonName; + case 'omeka-s-theme': + return 'composer-addons/themes/' . $addonName; default: - throw new \InvalidArgumentException('Invalid Omeka S addon package type'); + throw new \InvalidArgumentException('Invalid Omeka S add-on 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/data/composer-addon-installer/src/AddonInstallerPlugin.php b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php index 8a98e7c2be..5899d83e6d 100644 --- a/application/data/composer-addon-installer/src/AddonInstallerPlugin.php +++ b/application/data/composer-addon-installer/src/AddonInstallerPlugin.php @@ -6,6 +6,11 @@ use Composer\IO\IOInterface; use Composer\Plugin\PluginInterface; +/** + * Composer plugin for Omeka S add-on installation. + * + * Registers the AddonInstaller for modules and themes. + */ class AddonInstallerPlugin implements PluginInterface { public function activate(Composer $composer, IOInterface $io) diff --git a/application/data/scripts/install-addon-deps.php b/application/data/scripts/install-addon-deps.php new file mode 100644 index 0000000000..ada90d9914 --- /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/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/Module/InfoReader.php b/application/src/Module/InfoReader.php new file mode 100644 index 0000000000..38df03902b --- /dev/null +++ b/application/src/Module/InfoReader.php @@ -0,0 +1,500 @@ + 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 (label, etc.). + */ +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. + * + * @var array|null + */ + protected static $installedAddons = null; + + /** + * 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', + ]; + + /** + * 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 can be derived from composer or defaults to 1.0.0. + 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 + { + // Start with ini info as base. + $info = $iniInfo ?: []; + + // 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($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, 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 first author. + if (isset($package['authors'][0])) { + $firstAuthor = $package['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 issues link. + if (isset($package['support']['issues']) && !isset($info['support_link'])) { + $info['support_link'] = $package['support']['issues']; + } + + // theme_link uses the same homepage as module_link. + if (isset($package['homepage']) && !isset($info['theme_link'])) { + $info['theme_link'] = $package['homepage']; + } + + 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); + }); + } + + /** + * 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'; + } + } + + 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 + { + $words = $this->projectNameToWords($projectName); + 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 + { + $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(self::PREFIX_PATTERN, '', $project); + $project = preg_replace(self::SUFFIX_PATTERN, '', $project); + + // Split kebab-case and capitalize each word. + $words = explode('-', $project); + return array_map('ucfirst', $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 in installed.json. + */ + protected function buildInfoFromPackage(array $package): array + { + $info = $this->mapPackageToInfo($package); + + // Default name from project name. + if (empty($info['name'])) { + $info['name'] = $this->projectNameToLabel($package['name'] ?? ''); + } + + // Default version, with 'v' prefix removed. + if (empty($info['version'])) { + $version = $package['version'] ?? '1.0.0'; + $info['version'] = ltrim($version, 'vV'); + } + + // 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; + } + + /** + * 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 cf483470d9..a562e4939d 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,54 +27,85 @@ 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. - 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); - 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; - } + // Load installed.json once for all Composer-installed modules. + $infoReader->loadComposerInstalled(); - // Module directory must contain Module.php - $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); - if (!$moduleFile->isReadable() || !$moduleFile->isFile()) { - $module->setState(ModuleManager::STATE_INVALID_MODULE); + // Get all modules from the filesystem. + // Scan local modules first so they take precedence over add-ons. + // 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 . '/composer-addons/modules', + ]; + $registered = []; + foreach ($modulePaths as $modulePath) { + if (!is_dir($modulePath)) { 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 (local takes precedence). + $moduleId = $dir->getBasename(); + if (isset($registered[$moduleId])) { + continue; + } + $registered[$moduleId] = true; + + $module = $manager->registerModule($moduleId); + + // 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(), '/composer-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. + if (!$infoReader->isValid($info)) { + $module->setState(ModuleManager::STATE_INVALID_INI); + 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. + $moduleFile = new SplFileInfo($dir->getPathname() . '/Module.php'); + if (!$moduleFile->isReadable() || !$moduleFile->isFile()) { + $module->setState(ModuleManager::STATE_INVALID_MODULE); + continue; + } + $module->setModuleFilePath($moduleFile->getRealPath()); + + // 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; + } + } } } @@ -123,7 +154,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) @@ -133,13 +164,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); } } @@ -149,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/src/Service/ThemeManagerFactory.php b/application/src/Service/ThemeManagerFactory.php index 6252eea7ed..1f0fb759f5 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,69 +20,111 @@ 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. - foreach (new DirectoryIterator(OMEKA_PATH . '/themes') as $dir) { - - // Theme must be a directory - if (!$dir->isDir() || $dir->isDot()) { - continue; - } - - $theme = $manager->registerTheme($dir->getBasename()); + // Load installed.json once for all Composer-installed themes. + $infoReader->loadComposerInstalled(); - // 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); + // Get all themes from the filesystem. + // Scan local themes first so they take precedence over add-ons. + // 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', + 'composer-addons/themes' => OMEKA_PATH . '/composer-addons/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 (local takes precedence). + $themeId = $dir->getBasename(); + if (isset($registered[$themeId])) { + continue; + } + $registered[$themeId] = true; + + $theme = $manager->registerTheme($themeId); + $theme->setBasePath($basePath); + + // 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(), '/composer-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. + if (!$infoReader->isValid($info)) { + $theme->setState(ThemeManager::STATE_INVALID_INI); + continue; + } + + // 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)) { + try { + $ini = $iniReader->fromFile($iniFile); + if (isset($ini['config'])) { + $configSpec = $ini['config']; + } + } catch (\Exception $e) { + // Ignore ini read errors for config section. + } + } + + $theme->setIni($info); + $theme->setConfigSpec($configSpec); + + // 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); + + // 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..fcf6660bd9 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 + */ + 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., 'composer-addons/themes' 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"; } /** @@ -139,7 +165,7 @@ public function getThumbnail($key = null) public function isConfigurable() { $configSpec = $this->getConfigSpec(); - return $configSpec && $configSpec['elements']; + return $configSpec && !empty($configSpec['elements']); } /** @@ -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); } } diff --git a/application/test/OmekaTest/Composer/AddonInstallerTest.php b/application/test/OmekaTest/Composer/AddonInstallerTest.php new file mode 100644 index 0000000000..b60a1d47df --- /dev/null +++ b/application/test/OmekaTest/Composer/AddonInstallerTest.php @@ -0,0 +1,83 @@ +markTestSkipped('Requires composer/composer dev dependency.'); + } + parent::setUp(); + } + + /** + * @dataProvider moduleNameProvider + */ + public function testInflectModuleName(string $packageName, string $expected): void + { + $package = $this->createMockPackage($packageName, 'omeka-s-module'); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider themeNameProvider + */ + public function testInflectThemeName(string $packageName, string $expected): void + { + $package = $this->createMockPackage($packageName, 'omeka-s-theme'); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals($expected, $result); + } + + public function testInstallerNameOverride(): void + { + $package = $this->createMockPackage('vendor/some-module', 'omeka-s-module', [ + 'installer-name' => 'CustomName', + ]); + $result = AddonInstaller::getInstallName($package); + $this->assertEquals('CustomName', $result); + } + + public function moduleNameProvider(): array + { + return [ + ['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'], + ]; + } + + public function themeNameProvider(): array + { + return [ + ['vendor/thanks-roy', 'thanks-roy'], + ['vendor/omeka-s-theme-repository', 'repository'], + ['vendor/my-custom-theme-theme', 'my-custom-theme'], + ['vendor/flavor-theme-omeka', 'flavor'], + ]; + } + + protected function createMockPackage(string $name, string $type, array $extra = []) + { + $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/Module/AutoloaderTest.php b/application/test/OmekaTest/Module/AutoloaderTest.php new file mode 100644 index 0000000000..c3b29ce65e --- /dev/null +++ b/application/test/OmekaTest/Module/AutoloaderTest.php @@ -0,0 +1,347 @@ +testModuleName = 'TestAutoloader_' . uniqid(); + $this->localModulePath = OMEKA_PATH . '/modules/' . $this->testModuleName; + $this->addonModulePath = OMEKA_PATH . '/composer-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 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 + * 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 composer-addons/modules/. + $reflection = new \ReflectionClass($className); + $this->assertStringContainsString('/modules/' . $this->testModuleName, $reflection->getFileName(), + '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'); + } + + /** + * Test that symlink from modules/ to addons/ does NOT trigger override. + * + * 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 composer-addons/modules/. + $this->createTestModule($this->addonModulePath, 'addon'); + + // Create symlink from modules/ to composer-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 . '/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'); + $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 . '/composer-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 composer-addons/modules/. + */ + public function testAutoloaderDoesNotInterveneForAddonOnlyModule() + { + // Create module only in composer-addons/modules/. + $this->createTestModule($this->addonModulePath, 'addon'); + + $localModule = OMEKA_PATH . '/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'); + + // 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 composer-addons/modules/'); + } + + /** + * Test partial override: only some classes exist in local module. + * + * 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 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'; + $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.'); + } + + // 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 modules/, not composer-addons/. + $reflection = new \ReflectionClass('Common\\TraitModule'); + $this->assertStringContainsString('/modules/Common/', $reflection->getFileName(), + 'Common\\TraitModule should be loaded from modules/Common/'); + $this->assertStringNotContainsString('/composer-addons/', $reflection->getFileName(), + 'Common\\TraitModule should NOT be loaded from addons/'); + } +} diff --git a/application/test/OmekaTest/Module/InfoReaderTest.php b/application/test/OmekaTest/Module/InfoReaderTest.php new file mode 100644 index 0000000000..775868c9d1 --- /dev/null +++ b/application/test/OmekaTest/Module/InfoReaderTest.php @@ -0,0 +1,503 @@ +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']); + // configurable from ini is a string, ModuleManagerFactory converts to bool. + $this->assertNotEmpty($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 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']); + } + + // ------------------------------------------------------------------------- + // 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 testConfigurableNotSetByDefault() + { + // configurable is now set by ModuleManagerFactory, not InfoReader. + // InfoReader only reads it from module.ini if present. + $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->assertArrayNotHasKey('configurable', $info); + } + + // ------------------------------------------------------------------------- + // 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/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php new file mode 100644 index 0000000000..18cc7ba140 --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/composer-addons/modules/TestAddonBasic/Module.php @@ -0,0 +1,16 @@ +" +description = "Test theme (addons/themes/) to verify themes/ takes precedence" +omeka_version_constraint = "^4.2" diff --git a/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml b/application/test/OmekaTest/Module/fixtures/composer-addons/themes/test-override/view/layout/layout.phtml new file mode 100644 index 0000000000..06c1208eeb --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/composer-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/modules/TestAddonOverride/Module.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php new file mode 100644 index 0000000000..2a2842d6f7 --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/Module.php @@ -0,0 +1,21 @@ +" +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/Module/fixtures/modules/TestAddonOverride/src/TestService.php b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php new file mode 100644 index 0000000000..79b141d4a2 --- /dev/null +++ b/application/test/OmekaTest/Module/fixtures/modules/TestAddonOverride/src/TestService.php @@ -0,0 +1,19 @@ +" +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 ?> +
+ + 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 new file mode 100644 index 0000000000..44da0a4aa2 --- /dev/null +++ b/application/test/OmekaTest/Service/ModuleManagerFactoryTest.php @@ -0,0 +1,692 @@ +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 . '/composer-addons/modules', $modulePaths); + } + + /** + * Test that modules/ comes before composer-addons/modules/ (for priority). + * + * Local modules in modules/ should take precedence over + * composer-installed modules in composer-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 . '/composer-addons/modules', $modulePaths); + + $this->assertNotFalse($modulesIndex, 'modules/ should be in module_paths'); + $this->assertNotFalse($addonsIndex, 'composer-addons/modules/ should be in module_paths'); + $this->assertLessThan($addonsIndex, $modulesIndex, 'modules/ should come before composer-addons/modules/'); + } + + /** + * Test that composer-addons/modules directory exists. + */ + public function testAddonsModulesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/modules'); + } + + /** + * Test that composer-addons/themes directory exists. + */ + public function testAddonsThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/composer-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' + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $this->assertNotNull($info); + $this->assertEquals('TestModuleComposer', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + } + + /** + * 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 + ); + + $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']); + } + + /** + * 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); + } + + // ------------------------------------------------------------------------- + // Tests: Priority between modules/ and composer-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. + */ + public function testLocalModuleTakesPrecedenceOverAddons() + { + $moduleName = 'TestPriority_' . uniqid(); + $localModulePath = OMEKA_PATH . '/modules/' . $moduleName; + $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName; + + $this->createdModules[] = $localModulePath; + $this->createdModules[] = $addonsModulePath; + + try { + // Create module in composer-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 composer-addons/modules/ only (no local override). + */ + public function testModuleInAddonsOnlyIsRecognized() + { + $moduleName = 'TestAddonsOnly_' . uniqid(); + $addonsModulePath = OMEKA_PATH . '/composer-addons/modules/' . $moduleName; + + $this->createdModules[] = $addonsModulePath; + + try { + $this->createModuleWithComposer( + OMEKA_PATH . '/composer-addons/modules', + $moduleName, + '1.5.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 module.config.php. + */ + 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'] + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($modulePath, 'module'); + + $factory = new ModuleManagerFactory(); + $isConfigurable = $this->invokeMethod($factory, 'isModuleConfigurable', [$modulePath, $info]); + + $this->assertTrue($isConfigurable); + } + + /** + * Test configurable defaults to false when not set. + */ + public function testConfigurableDefaultsFalse() + { + $modulePath = $this->createModuleWithComposer( + $this->testModulesPath, + 'NonConfigurableModule', + '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'); + + $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); + } + + /** + * 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); + } + + // ------------------------------------------------------------------------- + // 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 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. + * + * 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 directory. + $fixturesPath = __DIR__ . '/../Module/fixtures'; + $moduleName = 'TestAddonOverride'; + $localModulePath = $fixturesPath . '/modules/' . $moduleName; + $addonsModulePath = $fixturesPath . '/composer-addons/modules/' . $moduleName; + + // Verify fixtures exist. + $this->assertDirectoryExists($localModulePath, 'Local module fixture should exist'); + $this->assertDirectoryExists($addonsModulePath, 'Addons module fixture should exist'); + + $infoReader = new InfoReader(); + + // 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 '/composer-addons/modules/'. + $this->assertFalse( + strpos($localModulePath, '/composer-addons/modules/') !== false, + 'Local module path should not contain /composer-addons/modules/' + ); + + // For a module in composer-addons/modules/, strpos SHOULD find '/composer-addons/modules/'. + $this->assertTrue( + 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, '/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. + $info = $infoReader->read($localModulePath, 'module'); + + $this->assertStringContainsString( + 'Local', + $info['name'], + '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 new file mode 100644 index 0000000000..24f11b4f1a --- /dev/null +++ b/application/test/OmekaTest/Service/ThemeManagerFactoryTest.php @@ -0,0 +1,715 @@ +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 composer-addons/themes directory exists. + */ + public function testAddonsThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/composer-addons/themes'); + } + + /** + * Test that themes/ directory exists (for local/manual themes). + */ + public function testThemesDirectoryExists() + { + $this->assertDirectoryExists(OMEKA_PATH . '/themes'); + } + + /** + * 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 . '/composer-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('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); + } + } + + /** + * 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('/composer-addons/themes/', $theme->getPath()); + } + + /** + * 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 composer-addons/themes. + $themeName = 'TestPrecedence_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/composer-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('/composer-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' + ); + + $infoReader = new InfoReader(); + $info = $infoReader->read($themePath, 'theme'); + + $this->assertNotNull($info); + $this->assertEquals('TestThemeComposer', $info['name']); + $this->assertEquals('2.0.0', $info['version']); + // 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 + ); + + $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']); + } + + /** + * 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); + } + + // ------------------------------------------------------------------------- + // 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 composer-addons/themes/ + // ------------------------------------------------------------------------- + + /** + * Test theme in composer-addons/themes/ with composer.json only (no theme.ini). + */ + public function testThemeInAddonsWithComposerJsonOnly() + { + $themeName = 'TestAddonsComposer_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/composer-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', + ], + ]; + 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('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')); + } finally { + // Cleanup handled by tearDown. + } + } + + /** + * Test theme in composer-addons/themes/ with both theme.ini and composer.json. + */ + public function testThemeInAddonsWithBothSources() + { + $themeName = 'TestAddonsBoth_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/composer-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 composer-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. + */ + public function testLocalThemeTakesPrecedenceWithBothUsingComposer() + { + $themeName = 'TestPriorityComposer_' . uniqid(); + $localThemePath = OMEKA_PATH . '/themes/' . $themeName; + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; + + $this->createdThemes[] = $localThemePath; + $this->createdThemes[] = $addonsThemePath; + + try { + // Create theme in composer-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 composer-addons/themes/ only (no local override). + */ + public function testThemeInAddonsOnlyIsRecognized() + { + $themeName = 'TestAddonsOnly_' . uniqid(); + $addonsThemePath = OMEKA_PATH . '/composer-addons/themes/' . $themeName; + + $this->createdThemes[] = $addonsThemePath; + + try { + $this->createThemeWithComposer( + OMEKA_PATH . '/composer-addons/themes', + $themeName, + '1.5.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('composer-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); + } +} diff --git a/bootstrap.php b/bootstrap.php index 66f55b7711..ecb357a2c0 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -4,3 +4,40 @@ date_default_timezone_set('UTC'); require 'vendor/autoload.php'; + +/* + * 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 composer-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 . '/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)) { + return false; // No conflict, let Composer handle it + } + + // 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 diff --git a/composer-addons/README.md b/composer-addons/README.md new file mode 100644 index 0000000000..049c5790a7 --- /dev/null +++ b/composer-addons/README.md @@ -0,0 +1,134 @@ +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 `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). + + +Version compatibility +--------------------- + +Add-ons can require `omeka/omeka-s` to declare compatibility with a specific +Omeka version: + +```json +{ + "require": { + "omeka/omeka-s": "^4.0" + } +} +``` + +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`. + + +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. + +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 +--------------- + +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": { + "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" + } + } +} +``` + + +Manual installation +------------------- + +For add-ons installed manually via `git clone` in directory `modules/` or +`themes/`, dependencies are not downloaded automatically. Use the following +script 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 +``` + +### 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 +``` + + +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)). diff --git a/composer-addons/modules/.gitkeep b/composer-addons/modules/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/composer-addons/themes/.gitkeep b/composer-addons/themes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/composer.lock b/composer.lock index 9feb88b713..9cee8d87ef 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "82f262c0558d4379c2418e8cd4d5b39212ffb364" }, "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 composer-addons/modules/ and composer-addons/themes/.", "transport-options": { "relative": true }