<?php declare(strict_types=1);

/**
 * Installer facilement Omeka S
 *
 * Ce script a été réalisé pour l’Université des Antilles et l’Université de la Guyane.
 * @see https://manioc.org
 *
 * @copyright Daniel Berthereau, 2024-2025
 * @license http://www.cecill.info/licences/Licence_CeCILL_V2.1-en.txt
 *
 * Portions of code come from Omeka.
 *
 * This software is governed by the CeCILL license under French law and abiding
 * by the rules of distribution of free software.  You can use, modify and/ or
 * redistribute the software under the terms of the CeCILL license as circulated
 * by CEA, CNRS and INRIA at the following URL "http://www.cecill.info".
 *
 * As a counterpart to the access to the source code and rights to copy, modify
 * and redistribute granted by the license, users are provided only with a
 * limited warranty and the software's author, the holder of the economic
 * rights, and the successive licensors have only limited liability.
 *
 * In this respect, the user's attention is drawn to the risks associated with
 * loading, using, modifying and/or developing or reproducing the software by
 * the user in light of its specific status of free software, that may mean that
 * it is complicated to manipulate, and that also therefore means that it is
 * reserved for developers and experienced professionals having in-depth
 * computer knowledge. Users are therefore encouraged to load and test the
 * software's suitability as regards their requirements in conditions enabling
 * the security of their systems and/or data to be ensured and, more generally,
 * to use and operate it in the same conditions as regards security.
 *
 * The fact that you are presently reading this means that you have had
 * knowledge of the CeCILL license and that you accept its terms.
 */

const OMEKA_PATH = __DIR__;

/**
 * Adapted:
 * @see \Omeka\Stdlib\Cli
 * @see \Omeka\Stdlib\Environment
 * @see \EasyAdmin\Mvc\Controller\Plugin\Addons
 *
 * @see https://github.com/omeka/omeka-s/blob/develop/application/src/Stdlib/Cli.php
 * @see https://github.com/omeka/omeka-s/blob/develop/application/src/Stdlib/Environment.php
 * @see https://gitlab.com/Daniel-KM/Omeka-S-module-EasyAdmin/-/blob/master/src/Mvc/Controller/Plugin/Addons.php
 */
class Utils
{
    const OMEKA_VERSION = '4.1.1';
    const PHP_MINIMUM_VERSION = '7.4.0';
    const PHP_MINIMUM_VERSION_ID = 70400;
    const MYSQL_MINIMUM_VERSION = '5.7.9';
    const MARIADB_MINIMUM_VERSION = '10.2.6';
    const PHP_REQUIRED_EXTENSIONS = [
        'fileinfo',
        'json',
        'mbstring',
        'PDO',
        'pdo_mysql',
        'xml',
    ];

    const OMEKA_LOCALE = 'fr';
    const OMEKA_LOG_LEVEL = 'NOTICE';

    public function __invoke(): self
    {
        return $this;
    }

    public function log($message = null)
    {
        static $messages = [];
        if ($message !== null) {
            $messages[] = $message;
        }
        return $messages;
    }

    public function psrLog(string $message, array $context)
    {
        $log = preg_replace_callback(
            '~\{([A-Za-z0-9_.]+)\}~',
            fn ($matches) => $context[$matches[1]] ?? $matches[0],
            $message
        );
        return $this->log($log);
    }

    /**
     * Get a command path.
     *
     * Returns the path to the provided command or boolean false if the command
     * is not found.
     *
     * @param string $command
     * @return string|false
     */
    public function getCommandPath($command)
    {
        $command = sprintf('command -v %s', escapeshellarg($command));
        return $this->execute($command);
    }

    /**
     * Execute a command.
     *
     * Expects arguments to be properly escaped.
     *
     * @param string $command An executable command
     * @return string|false The command's standard output or false on error
     */
    public function execute($command)
    {
        if (function_exists('proc_open')) {
            return $this->procOpen($command);
        } elseif (function_exists('exec')) {
            return $this->exec($command);
        } else {
            return false;
        }
    }

    /**
     * Execute command using PHP's exec function.
     *
     * @link http://php.net/manual/en/function.exec.php
     * @param string $command
     * @return string|false
     */
    public function exec($command)
    {
        $output = null;
        $exitCode = null;
        exec($command, $output, $exitCode);
        if (0 !== $exitCode) {
            $this->log(sprintf('Command "%s" failed with status code %s.', $command, $exitCode));
            return false;
        }
        return implode(PHP_EOL, $output);
    }

    /**
     * Execute command using PHP's proc_open function.
     *
     * For servers that allow proc_open. Logs standard error.
     *
     * @link http://php.net/manual/en/function.proc-open.php
     * @param string $command
     * @return string|false
     */
    public function procOpen($command)
    {
        $descriptorSpec = [
            0 => ['pipe', 'r'], // STDIN
            1 => ['pipe', 'w'], // STDOUT
            2 => ['pipe', 'w'], // STDERR
        ];

        $pipes = [];
        $proc = proc_open($command, $descriptorSpec, $pipes, getcwd());
        if (!is_resource($proc)) {
            return false;
        }

        // Set non-blocking mode on STDOUT and STDERR.
        stream_set_blocking($pipes[1], false);
        stream_set_blocking($pipes[2], false);

        // Poll STDOUT and STDIN in a loop, waiting for EOF. We do this to avoid
        // issues with stream_get_contents() where either stream could hang.
        $output = '';
        $errors = '';
        while (!feof($pipes[1]) || !feof($pipes[2])) {
            // Sleep to avoid tight busy-looping on the streams
            usleep(25000);
            if (!feof($pipes[1])) {
                $output .= stream_get_contents($pipes[1]);
            }
            if (!feof($pipes[2])) {
                $errors .= stream_get_contents($pipes[2]);
            }
        }

        foreach ($pipes as $pipe) {
            fclose($pipe);
        }

        $exitCode = proc_close($proc);
        if (0 !== $exitCode) {
            // Log standard error if any.
            if (strlen($errors)) {
                $this->log($errors);
            }
            $this->log(sprintf('Command "%s" failed with status code %s.', $command, $exitCode));
            return false;
        }

        return trim($output);
    }

    /**
     * Helper to download a file.
     *
     * @param string $source
     * @param string $destination
     * @return bool
     */
    public function downloadFile($source, $destination): bool
    {
        $handle = @fopen($source, 'rb');
        if (empty($handle)) {
            return false;
        }
        $result = (bool) file_put_contents($destination, $handle);
        @fclose($handle);
        return $result;
    }

    /**
     * Helper to unzip a file.
     *
     * @param string $source A local file.
     * @param string $destination A writeable dir.
     * @return bool
     */
    public function unzipFile($source, $destination): bool
    {
        // Unzip via php-zip.
        if (class_exists('ZipArchive')) {
            $zip = new ZipArchive;
            $result = $zip->open($source);
            if ($result === true) {
                $result = $zip->extractTo($destination);
                $zip->close();
            } else {
                $zipErrors = [
                    ZipArchive::ER_EXISTS => 'File already exists',
                    ZipArchive::ER_INCONS => 'Zip archive inconsistent',
                    ZipArchive::ER_INVAL => 'Invalid argument',
                    ZipArchive::ER_MEMORY => 'Malloc failure',
                    ZipArchive::ER_NOENT => 'No such file',
                    ZipArchive::ER_NOZIP => 'Not a zip archive',
                    ZipArchive::ER_OPEN => 'Can’t open file',
                    ZipArchive::ER_READ => 'Read error',
                    ZipArchive::ER_SEEK => 'Seek error',
                ];
                $this->log(sprintf('Erreur lors de la décompression : %s', $zipErrors[$result] ?? 'Other zip error'));
                $result = false;
            }
        }

        // Unzip via command line
        else {
            // Check if the zip command exists.
            try {
                $status = $output = $errors = null;
                $this->execute('unzip', $status, $output, $errors);
            } catch (Exception $e) {
                $status = 1;
            }
            // A return value of 0 indicates the convert binary is working correctly.
            $result = $status == 0;
            if ($result) {
                $command = 'unzip ' . escapeshellarg($source) . ' -d ' . escapeshellarg($destination);
                try {
                    $this->execute($command, $status, $output, $errors);
                } catch (Exception $e) {
                    $status = 1;
                }
                $result = $status == 0;
            }
        }

        return $result;
    }

    /**
     * Move all files and dirs from a directory to another one.
     */
    public function moveFilesFromDirToDir($source, $destination): bool
    {
        if ($source === $destination) {
            return true;
        }
        if (!file_exists($source) || !is_dir($source) || !is_readable($source) || !is_writeable($source)) {
            return false;
        }
        if (!file_exists($destination)) {
            mkdir($destination, 0775, true);
        }
        if (!file_exists($destination) || !is_dir($destination) || !is_writeable($destination)) {
            return false;
        }
        // Since rename() moves all the contents of a directory, only the root
        // files and dirs needs to be processed.
        $filesOrDirs = array_diff(scandir($source) ?: [], ['.', '..']);
        $result = true;
        foreach ($filesOrDirs as $fileOrDir) {
            $sourceFileOrDir = $source . DIRECTORY_SEPARATOR . $fileOrDir;
            $destinationFileOrDir = $destination . DIRECTORY_SEPARATOR . $fileOrDir;
            $result = rename($sourceFileOrDir, $destinationFileOrDir);
            if (!$result) {
                $this->log(sprintf('Impossible de déplacer %1$s', $fileOrDir));
                break;
            }
        }
        return $result;
    }

    /**
     * Remove all files and dirs of a dir from the filesystem.
     *
     * It does not remove the passed dir.
     *
     * @param string $dirpath Absolute path.
     * @param array $except Absolute paths. Only root paths can be skipped.
     */
    public function removeFilesAndDirsInDir(string $dirPath, array $except = []): bool
    {
        if (!file_exists($dirPath)) {
            return true;
        }
        if ($dirPath === '/'
            || strpos($dirPath, '/..') !== false
            || substr($dirPath, 0, 1) !== '/'
        ) {
            return false;
        }
        // Process the first level here and use rmDir for other ones.
        $filesOrDirs = array_diff(scandir($dirPath) ?: [], ['.', '..']);
        foreach ($filesOrDirs as $fileOrDir) {
            $path = $dirPath . '/' . $fileOrDir;
            if (in_array($path, $except)) {
                continue;
            }
            if (is_dir($path)) {
                $this->rmDir($path);
            } else {
                unlink($path);
            }
        }
        return true;
    }

    /**
     * Remove a dir from filesystem.
     *
     * @param string $dirpath Absolute path.
     */
    public function rmDir(string $dirPath): bool
    {
        if (!file_exists($dirPath)) {
            return true;
        }
        if ($dirPath === '/'
            || strpos($dirPath, '/..') !== false
            || substr($dirPath, 0, 1) !== '/'
        ) {
            return false;
        }
        $files = array_diff(scandir($dirPath) ?: [], ['.', '..']);
        foreach ($files as $file) {
            $path = $dirPath . '/' . $file;
            if (is_dir($path)) {
                $this->rmDir($path);
            } else {
                unlink($path);
            }
        }
        return rmdir($dirPath);
    }
}

/**
 * Version simplifiée de Easy Admin Addons.
 *
 * @see \EasyAdmin\Mvc\Controller\Plugin\Addons
 */
class Addons
{
    /**
     * @var Utils
     */
    protected $utils;

    /**
     * Source of data and destination of addons.
     *
     * @var array
     */
    protected $data = [
        'omekamodule' => [
            'source' => 'https://omeka.org/add-ons/json/s_module.json',
            'destination' => '/modules',
        ],
        'omekatheme' => [
            'source' => 'https://omeka.org/add-ons/json/s_theme.json',
            'destination' => '/themes',
        ],
        'module' => [
            'source' => 'https://raw.githubusercontent.com/Daniel-KM/UpgradeToOmekaS/master/_data/omeka_s_modules.csv',
            'destination' => '/modules',
        ],
        'theme' => [
            'source' => 'https://raw.githubusercontent.com/Daniel-KM/UpgradeToOmekaS/master/_data/omeka_s_themes.csv',
            'destination' => '/themes',
        ],
    ];

    /**
     * Cache for the list of addons.
     *
     * @var array
     */
    protected $addons = [];

    /**
     * Cache for the list of selections.
     *
     * @var array
     */
    protected $selections = [];

    public function __construct(?Utils $utils = null)
    {
        $this->utils = $utils ?? new Utils();
    }

    public function  __invoke(): self
    {
        return $this;
    }

    public function getAddons(bool $refresh = false): array
    {
        $this->initAddons($refresh);
        return $this->addons;
    }

    /**
     * Get curated selections of modules from the web.
     */
    public function getSelections(): array
    {
        static $list = [];

        if ($list) {
            $this->selections = $list;
            return$list;
        }

        $this->selections = [];
        $csv = @file_get_contents('https://raw.githubusercontent.com/Daniel-KM/UpgradeToOmekaS/refs/heads/master/_data/omeka_s_selections.csv');
        if ($csv) {
            // Get the column for name and modules.
            $headers = [];
            $isFirst = true;
            foreach (explode("\n", $csv) as $row) {
                $row = str_getcsv($row) ?: [];
                if ($isFirst) {
                    $headers = array_flip($row);
                    $isFirst = false;
                } elseif ($row) {
                    $name = $row[$headers['Name']] ?? '';
                    if ($name) {
                        $this->selections[$name] = array_map('trim', explode(',', $row[$headers['Modules and themes']] ?? ''));
                    }
                }
            }
        }

        return $list = $this->selections;
    }

    /**
     * Get the list of default types.
     */
    public function types(): array
    {
        return array_keys($this->data);
    }

    /**
     * Get addon data from the namespace of the module.
     */
    public function dataFromNamespace(string $namespace, ?string $type = null): array
    {
        $listAddons = $this->getAddons();
        $list = $type
            ? (isset($listAddons[$type]) ? [$type => $listAddons[$type]] : [])
            : $listAddons;
        foreach ($list as $type => $addonsForType) {
            $addonsUrl = array_column($addonsForType, 'url', 'dir');
            if (isset($addonsUrl[$namespace]) && isset($addonsForType[$addonsUrl[$namespace]])) {
                return $addonsForType[$addonsUrl[$namespace]];
            }
        }
        return [];
    }

    /**
     * Get addon data from the url of the repository.
     */
    public function dataFromUrl(string $url, string $type): array
    {
        $listAddons = $this->getAddons();
        return $listAddons && isset($listAddons[$type][$url])
            ? $listAddons[$type][$url]
            : [];
    }

    /**
     * Check if an addon is installed.
     *
     * @param array $addon
     */
    public function dirExists($addon): bool
    {
        $destination = OMEKA_PATH . $this->data[$addon['type']]['destination'];
        $existings = $this->listDirsInDir($destination);
        $existings = array_map('strtolower', $existings);
        return in_array(strtolower($addon['dir']), $existings)
            || in_array(strtolower($addon['basename']), $existings);
    }

    protected function initAddons(bool $refresh = false): self
    {
        static $lists;

        if ($lists) {
            $this->addons = $lists;
            return $this;
        }

        $this->addons = [];
        foreach ($this->types() as $addonType) {
            $this->addons[$addonType] = $this->listAddonsForType($addonType);
        }

        $lists = $this->addons;

        return $this;
    }

    /**
     * Helper to list the addons from a web page.
     *
     * @param string $type
     */
    protected function listAddonsForType($type): array
    {
        if (!isset($this->data[$type]['source'])) {
            return [];
        }
        $source = $this->data[$type]['source'];

        $content = $this->fileGetContents($source);
        if (empty($content)) {
            return [];
        }

        switch ($type) {
            case 'module':
            case 'theme':
                return $this->extractAddonList($content, $type);
            case 'omekamodule':
            case 'omekatheme':
                return $this->extractAddonListFromOmeka($content, $type);
        }
    }

    /**
     * Helper to get content from an external url.
     *
     * @param string $url
     */
    protected function fileGetContents($url): ?string
    {
        return file_get_contents($url) ?: null;
    }

    /**
     * Helper to parse a csv file to get urls and names of addons.
     *
     * @param string $csv
     * @param string $type
     */
    protected function extractAddonList($csv, $type): array
    {
        $list = [];

        $addons = array_map('str_getcsv', explode(PHP_EOL, $csv));
        $headers = array_flip($addons[0]);

        foreach ($addons as $key => $row) {
            if ($key == 0 || empty($row) || !isset($row[$headers['Url']])) {
                continue;
            }

            $url = $row[$headers['Url']];
            $name = $row[$headers['Name']];
            $version = $row[$headers['Last version']];
            $addonName = preg_replace('~[^A-Za-z0-9]~', '', $name);
            $dirname = $row[$headers['Directory name']] ?: $addonName;
            $server = strtolower(parse_url($url, PHP_URL_HOST));
            $dependencies = empty($headers['Dependencies']) || empty($row[$headers['Dependencies']])
                ? []
                : array_filter(array_map('trim', explode(',', $row[$headers['Dependencies']])));

            $zip = $row[$headers['Last released zip']];
            // Warning: the url with master may not have dependencies.
            if (!$zip) {
                switch ($server) {
                    case 'github.com':
                        $zip = $url . '/archive/master.zip';
                        break;
                    case 'gitlab.com':
                        $zip = $url . '/repository/archive.zip';
                        break;
                    default:
                        $zip = $url . '/master.zip';
                        break;
                }
            }

            $addon = [];
            $addon['type'] = $type;
            $addon['server'] = $server;
            $addon['name'] = $name;
            $addon['basename'] = basename($url);
            $addon['dir'] = $dirname;
            $addon['version'] = $version;
            $addon['url'] = $url;
            $addon['zip'] = $zip;
            $addon['dependencies'] = $dependencies;

            $list[$url] = $addon;
        }

        return $list;
    }

    /**
     * Helper to parse html to get urls and names of addons.
     *
     * @todo Manage dependencies for addon from omeka.org.
     *
     * @param string $json
     * @param string $type
     */
    protected function extractAddonListFromOmeka($json, $type): array
    {
        $list = [];

        $addonsList = json_decode($json, true);
        if (!$addonsList) {
            return [];
        }

        foreach ($addonsList as $name => $data) {
            if (!$data) {
                continue;
            }

            $version = $data['latest_version'];
            $url = 'https://github.com/' . $data['owner'] . '/' . $data['repo'];
            // Warning: the url with master may not have dependencies.
            $zip = $data['versions'][$version]['download_url'] ?? $url . '/archive/master.zip';

            $addon = [];
            $addon['type'] = str_replace('omeka', '', $type);
            $addon['server'] = 'omeka.org';
            $addon['name'] = $name;
            $addon['basename'] = $data['dirname'];
            $addon['dir'] = $data['dirname'];
            $addon['version'] = $data['latest_version'];
            $addon['url'] = $url;
            $addon['zip'] = $zip;
            $addon['dependencies'] = [];

            $list[$url] = $addon;
        }

        return $list;
    }

    /**
     * Helper to install an addon.
     */
    public function installAddon(array $addon): bool
    {
        switch ($addon['type']) {
            case 'module':
                $destination = OMEKA_PATH . '/modules';
                $type = 'module';
                break;
            case 'theme':
                $destination = OMEKA_PATH . '/themes';
                $type = 'theme';
                break;
            default:
                return false;
        }

        if (file_exists($destination . DIRECTORY_SEPARATOR . $addon['dir'])) {
            return true;
        }

        $zipFile = $destination . DIRECTORY_SEPARATOR . basename($addon['zip']);

        // Get the zip file from server.
        $result = $this->utils->downloadFile($addon['zip'], $zipFile);
        if (!$result) {
            $this->utils->psrLog(
                'Impossible de télécharger le {type} "{name}".', // @translate
                ['type' => $type, 'name' => $addon['name']]
            );
            return false;
        }

        // Unzip downloaded file.
        $result = $this->utils->unzipFile($zipFile, $destination);

        unlink($zipFile);

        if (!$result) {
            $this->utils->psrLog(
                'Une erreur s’est produite durant la décompression du {type} "{name}".', // @translate
                ['type' => $type, 'name' => $addon['name']]
            );
            return false;
        }

        // Move the addon to its destination.
        $this->moveAddon($addon);

        return true;
    }

    /**
     * Helper to rename the directory of an addon.
     *
     * The name of the directory is unknown, because it is a subfolder inside
     * the zip file, and the name of the module may be different from the name
     * of the directory.
     * @todo Get the directory name from the zip.
     *
     * @param string $addon
     * @return bool
     */
    protected function moveAddon($addon): bool
    {
        switch ($addon['type']) {
            case 'module':
                $destination = OMEKA_PATH . '/modules';
                break;
            case 'theme':
                $destination = OMEKA_PATH . '/themes';
                break;
            default:
                return false;
        }

        // Allows to manage case like AddItemLink, where the project name on
        // github is only "AddItem".
        $loop = [$addon['dir']];
        if ($addon['basename'] != $addon['dir']) {
            $loop[] = $addon['basename'];
        }

        // Manage only the most common cases.
        // @todo Use a scan dir + a regex.
        $checks = [
            ['', ''],
            ['', '-master'],
            ['', '-module-master'],
            ['', '-theme-master'],
            ['omeka-', '-master'],
            ['omeka-s-', '-master'],
            ['omeka-S-', '-master'],
            ['module-', '-master'],
            ['module_', '-master'],
            ['omeka-module-', '-master'],
            ['omeka-s-module-', '-master'],
            ['omeka-S-module-', '-master'],
            ['theme-', '-master'],
            ['theme_', '-master'],
            ['omeka-theme-', '-master'],
            ['omeka-s-theme-', '-master'],
            ['omeka-S-theme-', '-master'],
            ['omeka_', '-master'],
            ['omeka_s_', '-master'],
            ['omeka_S_', '-master'],
            ['omeka_module_', '-master'],
            ['omeka_s_module_', '-master'],
            ['omeka_S_module_', '-master'],
            ['omeka_theme_', '-master'],
            ['omeka_s_theme_', '-master'],
            ['omeka_S_theme_', '-master'],
            ['omeka_Module_', '-master'],
            ['omeka_s_Module_', '-master'],
            ['omeka_S_Module_', '-master'],
            ['omeka_Theme_', '-master'],
            ['omeka_s_Theme_', '-master'],
            ['omeka_S_Theme_', '-master'],
        ];

        $source = '';
        foreach ($loop as $addonName) {
            foreach ($checks as $check) {
                $sourceCheck = $destination . DIRECTORY_SEPARATOR
                    . $check[0] . $addonName . $check[1];
                if (file_exists($sourceCheck)) {
                    $source = $sourceCheck;
                    break 2;
                }
                // Allows to manage case like name is "Ead", not "EAD".
                $sourceCheck = $destination . DIRECTORY_SEPARATOR
                    . $check[0] . ucfirst(strtolower($addonName)) . $check[1];
                if (file_exists($sourceCheck)) {
                    $source = $sourceCheck;
                    $addonName = ucfirst(strtolower($addonName));
                    break 2;
                }
                if ($check[0]) {
                    $sourceCheck = $destination . DIRECTORY_SEPARATOR
                        . ucfirst($check[0]) . $addonName . $check[1];
                    if (file_exists($sourceCheck)) {
                        $source = $sourceCheck;
                        break 2;
                    }
                    $sourceCheck = $destination . DIRECTORY_SEPARATOR
                        . ucfirst($check[0]) . ucfirst(strtolower($addonName)) . $check[1];
                    if (file_exists($sourceCheck)) {
                        $source = $sourceCheck;
                        $addonName = ucfirst(strtolower($addonName));
                        break 2;
                    }
                }
            }
        }

        if ($source === '') {
            return false;
        }

        $path = $destination . DIRECTORY_SEPARATOR . $addon['dir'];
        if ($source === $path) {
            return true;
        }

        return rename($source, $path);
    }

    /**
     * List directories in a directory, not recursively.
     *
     * @param string $dir
     */
    protected function listDirsInDir($dir): array
    {
        static $dirs;

        if (isset($dirs[$dir])) {
            return $dirs[$dir];
        }

        if (empty($dir) || !file_exists($dir) || !is_dir($dir) || !is_readable($dir)) {
            return [];
        }

        $list = array_filter(array_diff(scandir($dir), ['.', '..']), fn ($file) => is_dir($dir . DIRECTORY_SEPARATOR . $file));

        $dirs[$dir] = $list;
        return $dirs[$dir];
    }
}

// La plupart des tests sont indépendants, sauf pour la base de données qui
// nécessite un test sur php.
// On peut donc afficher la plupart des problèmes en une seule fois.

$utils = new Utils();
$addons = new Addons($utils);

$isValid = true;
$isSystemValid = true;
$isPhpValid = true;
$isDatabaseValid = true;
// $currentMod = null;
// $requireChmod = false;
$failed = false;

$isPost = filter_input(INPUT_SERVER, 'REQUEST_METHOD') === 'POST';
if ($isPost) {
    // TODO Mysqli permet aussi d’utiliser directement les paramètres définis par défaut, mais c’est rare, et de toute façon Omeka utilise pdo.
    $host = trim((string) filter_input(INPUT_POST, 'host'));
    $port = intval(trim((string) filter_input(INPUT_POST, 'port'))) ?: null;
    $socket = trim((string) filter_input(INPUT_POST, 'socket')) ?: null;
    $dbname = trim((string) filter_input(INPUT_POST, 'dbname'));
    $user = trim((string) filter_input(INPUT_POST, 'user'));
    $password = trim((string) filter_input(INPUT_POST, 'password'));
    $locale = trim((string) filter_input(INPUT_POST, 'locale'));
    $logLevel = filter_input(INPUT_POST, 'log_level');
    $selection = trim((string) filter_input(INPUT_POST, 'selection'));
} else {
    $host = 'localhost';
    $port = '';
    $socket = '';
    $dbname = '';
    $user = '';
    $password = '';
    // Omeka.
    $locale = $utils::OMEKA_LOCALE;
    $logLevel = $utils::OMEKA_LOG_LEVEL;
    $selection = '';
}

// Test du système de fichiers.

// Vérification des droits d'écriture par le serveur web.
$isReadableAndWriteable = is_readable(__DIR__) && is_writeable(__DIR__);
if (!$isReadableAndWriteable) {
    // $currentMod = substr(sprintf('%o', fileperms(__DIR__)), -4);
    try {
        $result = chmod(__DIR__, 0775);
        if (!$result || !is_readable(__DIR__) || !is_writeable(__DIR__)) {
            throw new Exception();
        }
        // $requireChmod = true;
        $isReadableAndWriteable = true;
    } catch (Exception $e) {
        $isReadableAndWriteable = false;
        $isSystemValid = false;
        $utils->log('Le serveur web n’a pas les droits d’écriture dans le dossier en cours.');
    }
}

// Test de l’écriture réelle du fichier, normalement inutile.
if ($isReadableAndWriteable) {
    $randomFile = substr(str_replace(['+', '/', '='], '', base64_encode(random_bytes(48))), 0, 8);
    $randomPath = __DIR__ . '/' . $randomFile;
    try {
        $result = file_put_contents($randomPath, 'test');
        if (!$result) {
            throw new Exception();
        }
        unlink($randomPath);
    } catch (Exception $e) {
        $isSystemValid = false;
        $utils->log('Le serveur web ne peut pas écrire de fichier dans le dossier en cours.');
    }
}

// Test si le dossier est vide (sauf le présent fichier).
// TODO Le dossier pourrait ne pas être vide et convenir : test si le dossier ne contient pas les fichiers et dossiers Omeka.
$result = scandir(__DIR__);
if ($result === false) {
    $isSystemValid = false;
    $utils->log('Le serveur web ne peut pas compter les fichiers existants dans le dossier en cours.');
} elseif (count($result) > 3) {
    // Scandir compte « . », « .. » et le présent fichier.
    $isSystemValid = false;
    $utils->log('Le dossier en cours n’est pas vide.');
}

// Vérification complémentaire : unzip ou l’extension zip doivent être
// installés pour décompresser le fichier zip.
if (!extension_loaded('zip')) {
    try {
        $command = $utils->getCommandPath('unzip');
        if (!$command) {
            throw new Exception;
        }
    } catch (Exception $e) {
        $isSystemValid = false;
        $utils->log('L’extension php « zip » ou la commande « unzip » doivent être disponibles pour décompresser Omeka.');
    }
}

// Test si le serveur a accès à internet.
$fileToDownload = 'https://raw.githubusercontent.com/omeka/omeka-s/refs/heads/develop/README.md';
try {
    $result = $utils->downloadFile($fileToDownload, __DIR__ . '/README.md');
    if (!$result) {
        throw new Exception;
    }
    unlink(__DIR__ . '/README.md');
} catch (Exception $e) {
    $isSystemValid = false;
    $utils->log('Impossible de télécharger un fichier depuis le site github.com.');
}

// Test de la version php.
if (PHP_VERSION_ID < Utils::PHP_MINIMUM_VERSION_ID) {
    $isPhpValid = false;
    $utils->log(sprintf(
        'La version de PHP %1$s ne permet pas d’installer la dernière version d’Omeka S, qui requiert %2$s. ',
        PHP_VERSION, Utils::PHP_MINIMUM_VERSION
    ));
}

// Vérifcation des extensions php.
$result = [];
foreach (Utils::PHP_REQUIRED_EXTENSIONS as $extension) {
    if (!extension_loaded($extension)) {
        $result[] = $extension;
    }
}
if (count($result)) {
    $isPhpValid = false;
    $utils->log(sprintf(
        'La version de PHP %1$s permet d’installer la dernière version d’Omeka S, mais il manque %2$s sur le serveur : %3$s.',
        PHP_VERSION,
        count($result) === 1 ? 'l’extension php suivante' : 'les extensions php suivantes',
        implode(', ', $result)
    ));
    if (extension_loaded('intl')) {
        $utils->log('L’extension php « intl » est également recommandée pour une meilleure prise en charge des langues.');
    }
}

$isValid = $isValid && $isSystemValid && $isPhpValid;

// Vérification de la base de données (requiert php et les informations sur la base).

// On peut tester la base même s'il y a des problèmes dans le système de fichiers.
if ($isPhpValid && $isPost) {
    if (!preg_match('/^[\w.-]*$/', $host)) {
        $isDatabaseValid = false;
        $utils->log('Le nom d’hôte n’est pas conforme.');
    }
    if ($socket && !preg_match('~^[\w./-]*$~', $socket)) {
        $isDatabaseValid = false;
        $utils->log('Le socket n’est pas conforme.');
    }
    if ($socket && $host) {
        $isDatabaseValid = false;
        $utils->log('Il n’est pas possible de spécifier à la fois l’hôte et le socket.');
    }
    if (!preg_match('/^[\w.-]*$/', $dbname)) {
        $isDatabaseValid = false;
        $utils->log('Le nom de la base n’est pas conforme.');
    }
    if (!preg_match('/^[\w.-]*$/', $user)) {
        $isDatabaseValid = false;
        $utils->log('Le nom de l’utilisateur n’est pas conforme.');
    }
    if (!$password) {
        $isDatabaseValid = false;
        $utils->log('Le mot de passe est vide.');
    }

    // Vérifier complémentaire si l’utilisateur existe (via mysqli si disponible).
    if ($isDatabaseValid) {
        $dsn = $socket
            ? "mysql:unix_socket=$socket;charset=utf8mb4"
            : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4';
        $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
        try {
            $pdo = new PDO($dsn, $user, $password, $dbOptions);
            if (!$pdo) {
                $isDatabaseValid = false;
                $utils->log('La configuration du serveur ou de l’utilisateur (nom/mot de passe) n’est pas correcte.');
            } else {
                $pdo = null;
            }
        } catch (Exception $e) {
            $isDatabaseValid = false;
            $utils->log('La configuration du serveur ou de l’utilisateur (nom/mot de passe) n’est pas correcte.');
        }
    }

    // Vérifier la version de mysql/mariadb.
    if ($isDatabaseValid) {
        $dsn = $socket
            ? "mysql:unix_socket=$socket;charset=utf8mb4"
            : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4';
        $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
        try {
            $pdo = new PDO($dsn, $user, $password, $dbOptions);
            $sql = 'SELECT VERSION();';
            $stmt = $pdo->query($sql);
            $dbVersion = $stmt->fetchColumn();
            if (strpos($dbVersion, 'MariaDB') === false) {
                if (!version_compare($dbVersion, Utils::MYSQL_MINIMUM_VERSION, '>=')) {
                    $isDatabaseValid = false;
                    $utils->log(sprintf(
                        'La version de MySQL (%1$s) est inférieure à la version nécessaire pour Omeka (%2$s).',
                        $dbVersion, Utils::MYSQL_MINIMUM_VERSION
                    ));
                }
            } else {
                if (!version_compare($dbVersion, Utils::MARIADB_MINIMUM_VERSION, '>=')) {
                    $isDatabaseValid = false;
                    $utils->log(sprintf(
                        'La version de MariaDB (%1$s) est inférieure à la version nécessaire pour Omeka (%2$s).',
                        $dbVersion, Utils::MARIADB_MINIMUM_VERSION
                    ));
                }
            }
        } catch (Exception $e) {
            // La base de données n’est pas disponible pour l’utilisateur ou n’existe pas.
            $isDatabaseValid = false;
            $utils->log('La base de données est incorrecte.');
        }
    }

    // Vérifier si la base est vide ou créer une base vide.
    if ($isDatabaseValid) {
        // Vérifie si la base existe.
        $dsn = $socket
            ? "mysql:unix_socket=$socket;charset=utf8mb4"
            : "mysql:host=$host" . ($port ? ";port=$port" : '') . ';charset=utf8mb4';
        $dbOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
        try {
            $pdo = new PDO($dsn, $user, $password, $dbOptions);
            $sql = sprintf(
                'SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = %s;',
                $pdo->quote($dbname)
            );
            $stmt = $pdo->query($sql);
            $hasDatabase = (bool) $stmt->fetchColumn();
        } catch (Exception $e) {
            // La base de données n’est pas disponible pour l’utilisateur ou n’existe pas.
            $hasDatabase = false;
        }
        if ($hasDatabase) {
            // Vérifier si la base est bien vide.
            $dsnb = $socket
                ? "mysql:unix_socket=$socket;dbname=$dbname;charset=utf8mb4"
                : "mysql:host=$host" . ($port ? ";port=$port" : '') . ";dbname=$dbname;charset=utf8mb4";
            $pdo = new PDO($dsnb, $user, $password, $dbOptions);
            $stmt = $pdo->query('SHOW TABLES;');
            $hasTables = (bool) $stmt->fetchColumn();
            if ($hasTables) {
                $isDatabaseValid = false;
                $utils->log('La base de données doit être vide.');
            } else {
                $isDatabaseValid = true;
            }
        } else {
            // Sinon créer la base.
            try {
                $pdo = new PDO($dsn, $user, $password, $dbOptions);
                // Avec "create database", le nom de la base ne doit pas être quote(), mais « ` » si besoin.
                $sql = sprintf('CREATE DATABASE `%s`;', $dbname);
                $result = $pdo->exec($sql);
                if ($result === false) {
                    $isDatabaseValid = false;
                    $utils->log('Impossible de créer la base de données.');
                } else {
                    $isDatabaseValid = true;
                }
            } catch (Exception $e) {
                $isDatabaseValid = false;
            }
        }
    }
}

$isValid = $isValid && $isSystemValid && $isPhpValid && $isDatabaseValid;

// Préparation des sélections si l’environnement est valide.
$selections = $isSystemValid && $isPhpValid ? $addons->getSelections() : [];

// Preparation de l’installation.
if ($isValid && $isPost) {
    // Télécharger le zip.
    $fileToDownload = sprintf('https://github.com/omeka/omeka-s/releases/download/v%1$s/omeka-s-%1$s.zip', Utils::OMEKA_VERSION);
    $zipFile = __DIR__ . '/omeka-s.zip';
    $result = $utils->downloadFile($fileToDownload, $zipFile);
    if (!$result) {
        $failed = true;
        if (file_exists($zipFile)) {
            unlink($zipFile);
        }
        $utils->log('Impossible de télécharger le fichier.');
    }

    // Décompresser le zip dans le dossier en cours.
    if (!$failed) {
        $result = $utils->unzipFile($zipFile, __DIR__);
        if (!$result) {
            $failed = true;
            $utils->log('Impossible de décompresser le fichier.');
            // On supprime tous les fichiers créés, notamment .htaccess.
            $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]);
        }
        // Dans tous les cas, on supprime le fichier téléchargé.
        unlink($zipFile);
    }

    // Déplacer les fichiers depuis le dossier vers la racine du dossier en cours.
    if (!$failed) {
        // Le sous-dossier dans le zip est toujours « omeka-s ».
        $sourceDir = __DIR__ . '/omeka-s';
        $result = $utils->moveFilesFromDirToDir($sourceDir, __DIR__);
        if (!$result) {
            $failed = true;
            $utils->log('Impossible de déplacer les fichiers.');
            $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]);
        }
        $utils->rmDir(__DIR__ . '/omeka-s');
    }

    // Copier la configuration de la base de données dans database.ini.
    if (!$failed) {
        $dbIni = <<<INI
            user     = "$user"
            password = "$password"
            dbname   = "$dbname"
            host     = "$host"
            ;port     = $port
            ;unix_socket = "$socket"
            ;log_path = ""
            
            INI;
        if ($port) {
            $dbIni = str_replace(';port', 'port', $dbIni);
        }
        if ($socket) {
            $dbIni = str_replace(';unix_socket', 'unix_socket', $dbIni);
        }
        $result = file_put_contents(__DIR__ . '/config/database.ini', $dbIni);
        if (!$result) {
            $failed = true;
            $utils->log('Impossible de créer le fichier config/database.ini.');
            $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]);
        }
    }

    // Modifier les droits des dossiers et fichiers.
    // Normalement inutile.
    if (!$failed) {
        chmod(__DIR__ . '/files', 0775);
        chmod(__DIR__ . '/logs/application.log', 0775);
        chmod(__DIR__ . '/logs/sql.log', 0775);
    }

    // Modifier config.
    if (!$failed) {
        // var_export() ne peut pas être utilisé car il y a des constantes de
        // classes non disponibles et qu'en tout état de cause il est préférable
        // de conserver telles quelles.
        $configPath = __DIR__ . '/config/local.config.php';
        $config = file_get_contents($configPath);
        if ($locale) {
            $config = str_replace("'locale' => 'en_US',", sprintf("'locale' => '%s',", $locale), $config);
        }
        if ($logLevel !== 'none') {
            $config = str_replace("'log' => false,", "'log' => true,", $config);
            $config = str_replace(
                "'priority' => \Laminas\Log\Logger::NOTICE,",
                sprintf("'priority' => \Laminas\Log\Logger::%s,", $logLevel),
                $config
            );
        }
        file_put_contents($configPath, $config);
    }
}

$isFinalized = $isValid && $isPost && !$failed;

if ($isFinalized) {
    $selectionAddons = $selection ? $selections[$selection] ?? [] : [];
    /** @see \EasyAdmin\Job\ManageAddons */
    if ($selectionAddons) {
        // Initialisation des listes.
        $addons->getAddons();
        $unknowns = [];
        $existings = [];
        $errors = [];
        $installeds = [];
        // Eviter les problèmes.
        $selectionAddons = array_unique(array_merge(['Common', 'Generic'], array_values($selectionAddons)));
        foreach ($selectionAddons as $addonName) {
            $addon = $addons->dataFromNamespace($addonName);
            if (!$addon) {
                $unknowns[] = $addonName;
            } elseif ($addons->dirExists($addon)) {
                $existings[] = $addonName;
            } else {
                $result = $addons->installAddon($addon);
                if ($result) {
                    $installeds[] = $addonName;
                } else {
                    $errors[] = $addonName;
                }
            }
        }

        if (count($unknowns)) {
            $failed = true;
            $isFinalized = false;
            $utils->psrLog(
                'Les modules suivants de la sélection sont inconnus : {addons}.', // @translate
                ['addons' => implode(', ', $unknowns)]
            );
        }
        if (count($existings)) {
            $utils->psrLog(
                'Les modules suivants sont déjà installés : {addons}.', // @translate
                ['addons' => implode(', ', $existings)]
            );
        }
        if (count($errors)) {
            $failed = true;
            $isFinalized = false;
            $utils->psrLog(
                'Les modules suivants ne peuvent pas être installés : {addons}.', // @translate
                ['addons' => implode(', ', $errors)]
            );
        }
        if (count($installeds)) {
            $utils->psrLog(
                'Les modules suivants ont été installés : {addons}.', // @translate
                ['addons' => implode(', ', $installeds)]
            );
        }
    }

    if ($failed) {
        $utils->removeFilesAndDirsInDir(__DIR__, [__FILE__]);
    }
}

if ($isFinalized) {
    // Supprimer le présent fichier.
    unlink(__FILE__);

    // Préparer la redirection.
    $urlOmeka = filter_input(INPUT_SERVER, 'REQUEST_SCHEME')
        . '://'
        . filter_input(INPUT_SERVER, 'SERVER_NAME')
        . (in_array(filter_input(INPUT_SERVER, 'SERVER_PORT'), ['80', '443']) ? '' : ':' . filter_input(INPUT_SERVER, 'SERVER_PORT'))
        // Ne pas ajouter « index.php ».
        . dirname(filter_input(INPUT_SERVER, 'REQUEST_URI'));
}

$meta = [
    'title' => 'Installer Omeka S facilement',
    'author' => 'Daniel Berthereau',
    'description' => 'Installer Omeka S simplement avec un fichier unique à déposer sur le serveur.',
];

$locales = [
    '' => 'Défaut',
    'ca_ES' => 'Català (Espanya) [ca_ES]',
    'cs' => 'Čeština [cs]',
    'de_DE' => 'Deutsch (Deutschland) [de_DE]',
    'et' => 'Eesti [et]',
    'en_US' => 'English (United States) [en_US]',
    'es_419' => 'Español (Latinoamérica) [es_419]',
    'es' => 'Español [es]',
    'eu' => 'Euskara [eu]',
    'fr' => 'Français [fr]',
    'hr' => 'Hrvatski [hr]',
    'it' => 'Italiano [it]',
    'lt' => 'Lietuvių [lt]',
    'hu_HU' => 'Magyar (Magyarország) [hu_HU]',
    'nl_NL' => 'Nederlands (Nederland) [nl_NL]',
    'pl' => 'Polski [pl]',
    'pt_BR' => 'Português (Brasil) [pt_BR]',
    'pt_PT' => 'Português (Portugal) [pt_PT]',
    'ro' => 'Română [ro]',
    'fi_FI' => 'Suomi (Suomi) [fi_FI]',
    'sv_SE' => 'Svenska (Sverige) [sv_SE]',
    'tr_TR' => 'Türkçe (Türkiye) [tr_TR]',
    'el_GR' => 'Ελληνικά (Ελλάδα) [el_GR]',
    'bg_BG' => 'Български (България) [bg_BG]',
    'mn' => 'Монгол [mn]',
    'ru' => 'Русский [ru]',
    'sr_RS' => 'Српски (Србија) [sr_RS]',
    'uk' => 'Українська [uk]',
    'ar' => 'العربية [ar]',
    'ko_KR' => '한국어(대한민국) [ko_KR]',
    'zh_CN' => '中文(中国) [zh_CN]',
    'zh_TW' => '中文(台灣) [zh_TW]',
    'ja' => '日本語 [ja]',
];

?>
<!DOCTYPE html>
<html lang="fr" prefix="og: https://ogp.me/ns#">
    <head>
        <meta charSet="utf-8"/>
        <meta name="viewport" content="width=device-width,initial-scale=1"/>
        <meta name="author" content="<?= htmlspecialchars($meta['author']) ?>"/>
        <meta name="description" content="<?= htmlspecialchars($meta['description']) ?>"/>
        <meta property="og:title" content="<?= htmlspecialchars($meta['title']) ?>"/>
        <meta property="og:description" content="<?= htmlspecialchars($meta['description']) ?>"/>
        <?php if ($isFinalized && !$failed): ?>
        <meta http-equiv="refresh" content="10;url=<?= htmlspecialchars($urlOmeka) ?>"/>
        <?php endif; ?>
        <title><?= htmlspecialchars($meta['title']) ?></title>
        <style>
            header {
                padding: 5% 10% 0;
            }
            main {
                padding: 0 10%;
            }

            label {
                display: inline-block;
                width: 33%;
                text-align: right;
                margin-bottom: 1em;
                margin-right: 0.5em;
            }
            input {
                display: inline-block;
                width: 33%;
            }

            .radios > label {
                vertical-align: top;
            }
            .radio-group {
                display: inline-block;
                width: initial;
                width: 50%;
            }
            .radio-group label {
                display: inline;
                width: initial;
                text-align: initial;
            }
            .radio-group input {
                display: inline;
                width: initial;
            }

            details {
                display: block;
                margin-bottom: 1em;
            }
            summary {
                width: 33%;
                text-align: right;
                margin-bottom: 1em;
                font-style: italic;
            }
            summary:hover {
                cursor: pointer;
            }

            button {
                width: 50%;
                margin-left: 25%;
                margin-top: 4em;
            }
            button:hover {
                cursor: pointer;
            }
        </style>
    </head>
    <body>

        <header>
            <h1><?= htmlspecialchars($meta['title']) ?></h1>
        </header>

        <main>

            <?php if ($utils->log()): ?>
            <p>Omeka ne peut pas être installé en raison des erreurs suivantes :</p>
            <ul>
                <?php foreach ($utils->log() as $message): ?>
                <li><?= htmlspecialchars($message) ?></li>
                <?php endforeach; ?>
            </ul>
            <p>
                Vérifiez la configuration chez votre hébergeur.
            </p>
            <?php else: ?>
            <p>
                Aucun problème n’a été détecté sur le serveur.
            </p>
            <?php endif; ?>

            <?php if ($isSystemValid && $isPhpValid && !$isFinalized): ?>

            <form method="post">

                <h2>Base de données</h2>

                <p>
                    Omeka a besoin d’un accès à une base de données pour fonctionner.
                    Merci d’en indiquer les paramètres ci-dessous.
                    Si la base n’existe pas, l’utilisateur doit avoir les droits de création.
                </p>

                <label for="host">Serveur</label>
                <input type="text" id="host" name="host" value="<?= htmlspecialchars($host) ?>"/><br/>

                <label for="dbname">Nom de la base</label>
                <input type="text" id="dbname" name="dbname" required="required" value="<?= htmlspecialchars($dbname) ?>"/><br/>

                <label for="user">Utilisateur de la base</label>
                <input type="text" id="user" name="user" required="required" value="<?= htmlspecialchars($user) ?>"/><br/>

                <label for="password">Mot de passe</label>
                <input type="password" id="password" name="password" required="required" value="<?= htmlspecialchars($password) ?>"/><br/>

                <details>
                    <summary>Configuration spécifique</summary>
                    <label for="port">Port</label>
                    <input type="number" id="port" name="port" min="0" max="65535" value="<?= htmlspecialchars((string) $port) ?>"/><br/>

                    <label for="socket">Socket</label>
                    <input type="text" id="socket" name="socket" value="<?= htmlspecialchars((string) $socket) ?>"/><br/>
                </details>

                <h2>Fichier de configuration</h2>

                <label for="locale">Langue par défaut</label>
                <select id="locale" name="locale">
                    <?php foreach ($locales as $code => $label): ?>
                    <option value="<?= $code ?>"<?= $code === $locale ? ' selected="selected"' : '' ?>><?= htmlspecialchars($label) ?></option>
                    <?php endforeach; ?>
                </select>

                <details>
                    <summary>Configuration spécifique</summary>
                    <div class="radios">
                        <label for="logLevel">Gravité minimale des journaux</label>
                        <div id="logLevel" class="radio-group">
                            <input type="radio" id="log_none" name="log_level" value="none"<?= $logLevel === 'none' ? ' checked="checked"' : '' ?>/>
                            <label for="log_none">Aucun</label>
                            <input type="radio" id="log_err" name="log_level" value="ERR"<?= $logLevel === 'ERR' ? ' checked="checked"' : '' ?>/>
                            <label for="log_err">Erreur</label>
                            <input type="radio" id="log_warn" name="log_level" value="WARN"<?= $logLevel === 'WARN' ? ' checked="checked"' : '' ?>/>
                            <label for="log_warn">Avertissement</label>
                            <br/>
                            <input type="radio" id="log_notice" name="log_level" value="NOTICE"<?= $logLevel === 'NOTICE' ? ' checked="checked"' : '' ?>/>
                            <label for="log_notice">Note</label>
                            <input type="radio" id="log_info" name="log_level" value="INFO"<?= $logLevel === 'INFO' ? ' checked="checked"' : '' ?>/>
                            <label for="log_info">Info</label>
                            <input type="radio" id="log_debug" name="log_level" value="DEBUG"<?= $logLevel === 'DEBUG' ? ' checked="checked"' : '' ?>/>
                            <label for="log_debug">Débogage</label>
                        </div>
                    </div>
                    <?php // TODO Ajouter test et choix de la vignetteuse. ?>
                </details>

                <?php if ($selections): ?>

                <h2>Préinstallation de modules et thèmes</h2>
                <p>
                    Les <a href="https://daniel-km.github.io/UpgradeToOmekaS/fr/omeka_s_selections.html" target="_blank" rel="noopener">sélections</a> sont des listes de modules et de thèmes permettant de disposer rapidement d'une installation adaptée à ses besoins.
                </p>

                <label for="selection">Sélection</label>
                <select id="selection" name="selection">
                    <option value=""></option>
                    <?php foreach (array_keys($selections) as $name): ?>
                    <option value="<?= $name ?>"<?= $name === $selection ? ' selected="selected"' : '' ?>><?= htmlspecialchars($name) ?></option>
                    <?php endforeach; ?>
                </select>

                <?php endif; ?>

                <button type="submit" class="button">
                    <h2>Installer Omeka S</h2>
                </button>

            </form>

        <?php elseif ($isFinalized): ?>

        <p>
            Bravo, Omeka est préinstallé ! Vous pouvez désormais <a href="<?= htmlspecialchars($urlOmeka) ?>">finaliser l’installation</a> ou patientez dix secondes pour y aller automatiquement.
        </p>

        <?php endif; ?>

        </main>

    </body>
</html>