Fix migrations and deploy issues + errors + linting

- scan both pending/ and applied/ dirs so remote catch-up works
- fix remote 500s: run.php handles per-statement errors so VIEW rebuilds run after duplicate columns; replace mb_strimwidth with substr (no mbstring extension on server)
- add missing migration: 015_license_custom.sql (column existed in schema.sql but was never migrated)
- remote: fgetcsv enclosure single-char + AdminLogger permission-denied
guard + deploy always migrates
- fix admin-filters wrapping: restore flex-wrap, flex-basis on
inputs/selects, shrink-protect buttons
- fix phpstan: remove redundant ?? [] after isset guard in
ThesisEditController
- biome: exclude vendored min.js via includes patterns;
lint whole js dir; modernise beforeunload-guard.js
This commit is contained in:
Pontoporeia
2026-05-07 23:45:09 +02:00
parent bdd95341b0
commit e3896811c4
15 changed files with 153 additions and 71 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE theses ADD COLUMN license_custom TEXT;

View File

@@ -7,8 +7,8 @@
*
* If no DB_PATH is given, defaults to storage/xamxam.db.
*
* Each migration in migrations/pending/ is applied in alphabetical order.
* After success, the file is moved to migrations/applied/.
* Scans both migrations/pending/ and migrations/applied/ for .sql files.
* Each is applied in alphabetical order if not already tracked in _migrations.
*/
$root = dirname(__DIR__);
@@ -34,17 +34,19 @@ $applied = $pdo->query("SELECT name FROM _migrations")->fetchAll(PDO::FETCH_COLU
$pendingDir = $root . '/migrations/pending';
$appliedDir = $root . '/migrations/applied';
if (!is_dir($pendingDir)) {
echo "No pending migrations directory.\n";
exit(0);
}
if (!is_dir($appliedDir)) {
mkdir($appliedDir, 0755, true);
}
$files = glob($pendingDir . '/*.sql');
sort($files);
// Collect .sql files from both pending and applied dirs
$files = [];
foreach ([$pendingDir, $appliedDir] as $dir) {
if (!is_dir($dir)) continue;
foreach (glob($dir . '/*.sql') as $f) {
$files[basename($f)] = $f;
}
}
ksort($files);
if (empty($files)) {
echo "No pending migration files.\n";
@@ -52,8 +54,7 @@ if (empty($files)) {
}
$count = 0;
foreach ($files as $file) {
$name = basename($file);
foreach ($files as $name => $file) {
if (in_array($name, $applied, true)) {
echo "Skip (already applied): $name\n";
@@ -63,21 +64,36 @@ foreach ($files as $file) {
echo "Applying: $name\n";
$sql = file_get_contents($file);
try {
$pdo->exec($sql);
$pdo->prepare("INSERT INTO _migrations (name) VALUES (?)")->execute([$name]);
rename($file, $appliedDir . '/' . $name);
$count++;
} catch (PDOException $e) {
$msg = $e->getMessage();
// SQLite: skip if column already exists
if (stripos($msg, 'duplicate column name') !== false) {
echo " Already exists (skipping): $name\n";
$pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]);
rename($file, $appliedDir . '/' . $name);
continue;
// Split into individual statements to handle partial failures gracefully
// (e.g. ALTER TABLE may fail with "duplicate column" but DROP VIEW must still run)
$statements = array_filter(
array_map('trim', explode(';', $sql)),
fn($s) => $s !== ''
);
$errors = [];
foreach ($statements as $stmt) {
try {
$pdo->exec($stmt . ';');
} catch (PDOException $e) {
$msg = $e->getMessage();
if (stripos($msg, 'duplicate column name') !== false) {
echo " Skipping (column exists): " . substr($stmt, 0, 60) . "...\n";
continue;
}
$errors[] = $msg;
}
throw $e;
}
if (empty($errors)) {
$pdo->prepare("INSERT OR REPLACE INTO _migrations (name) VALUES (?)")->execute([$name]);
if (str_starts_with($file, $pendingDir)) {
rename($file, $appliedDir . '/' . $name);
}
$count++;
} else {
echo " FAILED: " . implode(' | ', $errors) . "\n";
throw new PDOException(implode(' | ', $errors));
}
}

View File

@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
'autorisation', 'licence', 'license', 'taille', 'points', 'lien baiu',
];
for ($scan = 0; $scan < 8; $scan++) {
$hrow = fgetcsv($handle, 0, ',', '\"', '');
$hrow = fgetcsv($handle, 0, ',', '"', '');
if ($hrow === false) break;
$headerRowNum++;
$normRow = array_map(fn($s) => strtolower(trim((string)$s)), $hrow);
@@ -84,7 +84,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$idPos = $colIdx['identifiant'] ?? 0;
$peekRow = null;
while (true) {
$peek = fgetcsv($handle, 0, ',', '\"', '');
$peek = fgetcsv($handle, 0, ',', '"', '');
if ($peek === false) break;
$headerRowNum++;
$val = trim((string)($peek[$idPos] ?? ''));
@@ -216,7 +216,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
$row = $peekRow;
$usePeek = false;
} else {
$row = fgetcsv($handle, 0, ',', '\"', '');
$row = fgetcsv($handle, 0, ',', '"', '');
if ($row === false) break;
}
$lineNumber++;

View File

@@ -737,7 +737,22 @@
.admin-list-toolbar .admin-filters {
flex: 1;
margin-bottom: 0;
flex-wrap: nowrap;
flex-wrap: wrap;
min-width: 0;
}
.admin-list-toolbar .admin-filters input[type="text"] {
min-width: 10rem;
flex: 1 1 10rem;
}
.admin-list-toolbar .admin-filters select {
min-width: 7rem;
flex: 1 1 7rem;
}
.admin-list-toolbar .admin-filters .btn {
flex-shrink: 0;
}
.admin-list-toolbar__right {

View File

@@ -4,20 +4,19 @@
* Attach to any form with a data-beforeunload-guard attribute.
* No effect when JavaScript is unavailable (form posts normally).
*/
(function () {
var forms = document.querySelectorAll('form[data-beforeunload-guard]');
(() => {
const forms = document.querySelectorAll('form[data-beforeunload-guard]');
if (!forms.length) return;
var dirty = false;
let dirty = false;
for (var i = 0; i < forms.length; i++) {
var form = forms[i];
form.addEventListener('input', function () { dirty = true; });
form.addEventListener('change', function () { dirty = true; });
form.addEventListener('submit', function () { dirty = false; });
for (const form of forms) {
form.addEventListener('input', () => { dirty = true; });
form.addEventListener('change', () => { dirty = true; });
form.addEventListener('submit', () => { dirty = false; });
}
window.addEventListener('beforeunload', function (e) {
window.addEventListener('beforeunload', (e) => {
if (dirty) {
e.preventDefault();
}

View File

@@ -257,7 +257,9 @@ class AdminLogger
}
$line = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
error_log($line, 3, $this->logFile);
if (is_writable($this->logFile) || (!file_exists($this->logFile) && is_writable(dirname($this->logFile)))) {
error_log($line, 3, $this->logFile);
}
if ($this->db !== null) {
$this->insertDb($resource, $action, $status, $context);

View File

@@ -122,7 +122,7 @@ class ExportController
*/
public function createExportZip(?string $baseDir = null): string
{
$baseDir = $baseDir ?? 'files';
$baseDir ??= 'files';
$storageRoot = defined('STORAGE_ROOT') ? STORAGE_ROOT : APP_ROOT . '/storage';
$files = $this->getAllThesisFiles();
$manifest = $this->buildExportManifest();

View File

@@ -196,7 +196,7 @@ class ThesisCreateController
]);
$identifier = $this->db->getThesisIdentifier($thesisId);
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . " author(s)");
error_log("ThesisCreateController: created thesis #$thesisId ($identifier) with " . count($authorEntries) . ' author(s)');
$this->db->setThesisAuthors($thesisId, $authorEntries);
$this->db->setThesisJury($thesisId, $data['juryMembers']);
@@ -298,7 +298,7 @@ class ThesisCreateController
$authorRaw = $this->sanitiseString($post['auteurice'] ?? '');
$authorNames = [];
if ($authorRaw !== '') {
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn($n) => $n !== '');
$authorNames = array_filter(array_map('trim', explode(',', $authorRaw)), fn ($n) => $n !== '');
$authorNames = array_values($authorNames);
sort($authorNames, SORT_NATURAL);
}

View File

@@ -165,46 +165,78 @@ class ThesisEditController
// ── Basic validation (same required fields as create) ──────────────────
$errors = [];
$titre = trim($post['titre'] ?? '');
if ($titre === '') $errors[] = 'Le titre est requis.';
if ($titre === '') {
$errors[] = 'Le titre est requis.';
}
$auteurice = trim($post['auteurice'] ?? '');
if ($auteurice === '') $errors[] = "L'auteur·ice est requis.";
if ($auteurice === '') {
$errors[] = "L'auteur·ice est requis.";
}
$synopsis = trim($post['synopsis'] ?? '');
if ($synopsis === '') $errors[] = 'Le synopsis est requis.';
if ($synopsis === '') {
$errors[] = 'Le synopsis est requis.';
}
$annee = intval($post['année'] ?? 0);
if ($annee < 2000 || $annee > ((int)date('Y') + 1)) $errors[] = "L'année est invalide.";
if ($annee < 2000 || $annee > ((int)date('Y') + 1)) {
$errors[] = "L'année est invalide.";
}
$orientationId = intval($post['orientation'] ?? 0);
if ($orientationId <= 0) $errors[] = "L'orientation est requise.";
if ($orientationId <= 0) {
$errors[] = "L'orientation est requise.";
}
$apProgramId = intval($post['ap'] ?? 0);
if ($apProgramId <= 0) $errors[] = "L'atelier pluridisciplinaire est requis.";
if ($apProgramId <= 0) {
$errors[] = "L'atelier pluridisciplinaire est requis.";
}
$finalityId = intval($post['finality'] ?? 0);
if ($finalityId <= 0) $errors[] = 'La finalité est requise.';
if ($finalityId <= 0) {
$errors[] = 'La finalité est requise.';
}
// Languages
$langIds = isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : [];
if (empty($langIds)) $errors[] = 'Au moins une langue est requise.';
if (empty($langIds)) {
$errors[] = 'Au moins une langue est requise.';
}
// Formats
$fmtIds = isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : [];
if (empty($fmtIds)) $errors[] = 'Au moins un format est requis.';
if (empty($fmtIds)) {
$errors[] = 'Au moins un format est requis.';
}
// Licence
$licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null;
$licenseCustom = trim($post['license_custom'] ?? '');
if (!$licenseId && $licenseCustom === '') $errors[] = 'Une licence est requise.';
if (!$licenseId && $licenseCustom === '') {
$errors[] = 'Une licence est requise.';
}
// Jury
$hasPromoteur = !empty(trim($post['jury_promoteur'] ?? ''));
$hasLecteurInt = false;
$hasLecteurExt = false;
foreach ($post['jury_lecteur_interne'] ?? [] as $n) {
if (trim((string)$n) !== '') { $hasLecteurInt = true; break; }
if (trim((string)$n) !== '') {
$hasLecteurInt = true;
break;
}
}
foreach ($post['jury_lecteur_externe'] ?? [] as $n) {
if (trim((string)$n) !== '') { $hasLecteurExt = true; break; }
if (trim((string)$n) !== '') {
$hasLecteurExt = true;
break;
}
}
if (!$hasPromoteur) {
$errors[] = 'Un·e promoteur·ice interne est requis.';
}
if (!$hasLecteurInt) {
$errors[] = 'Au moins un·e lecteur·ice interne est requis.';
}
if (!$hasLecteurExt) {
$errors[] = 'Au moins un·e lecteur·ice externe est requis.';
}
if (!$hasPromoteur) $errors[] = 'Un·e promoteur·ice interne est requis.';
if (!$hasLecteurInt) $errors[] = 'Au moins un·e lecteur·ice interne est requis.';
if (!$hasLecteurExt) $errors[] = 'Au moins un·e lecteur·ice externe est requis.';
if (!empty($errors)) {
throw new RuntimeException(implode(' ', $errors));
@@ -241,7 +273,7 @@ class ThesisEditController
$showContact = !empty($post['contact_public']);
$authorNames = [];
if ($authorsRaw !== '') {
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn($n) => $n !== ''));
$authorNames = array_values(array_filter(array_map('trim', explode(',', $authorsRaw)), fn ($n) => $n !== ''));
sort($authorNames, SORT_NATURAL);
}
$authorEntries = [];
@@ -402,7 +434,7 @@ class ThesisEditController
$authorName = trim($post['auteurice'] ?? 'unknown');
// Sort the raw comma-separated string alphabetically, then slugify.
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn($n) => $n !== ''));
$names = array_values(array_filter(array_map('trim', explode(',', $authorName)), fn ($n) => $n !== ''));
sort($names, SORT_NATURAL);
$authorSlug = $this->generateAuthorSlug(implode(', ', $names));
@@ -674,7 +706,7 @@ class ThesisEditController
// Backwards compat: old jury_lecteurs[]
if (isset($post['jury_lecteurs'])) {
foreach ($post['jury_lecteurs'] ?? [] as $i => $name) {
foreach ($post['jury_lecteurs'] as $i => $name) {
$name = trim($name);
if ($name !== '') {
$members[] = [

View File

@@ -1055,7 +1055,7 @@ class Database
// Levenshtein distance ≤ 10 % of the longer string.
// levenshtein() is limited to 255 chars; use substrings for safety.
$a = mb_substr($normNew, 0, 255);
$a = mb_substr($normNew, 0, 255);
$b = mb_substr($normExisting, 0, 255);
$dist = levenshtein($a, $b);
$threshold = (int)ceil($maxLen * 0.10);

View File

@@ -105,7 +105,7 @@
<div class="fhb-block-info">
<span class="fhb-block-label"><?= htmlspecialchars($b['label']) ?></span>
<?php if (trim($b['content']) !== ''): ?>
<span class="fhb-block-preview"><?= htmlspecialchars(mb_strimwidth(trim($b['content']), 0, 60, '…')) ?></span>
<span class="fhb-block-preview"><?= htmlspecialchars(strlen($preview = trim($b['content'])) > 60 ? substr($preview, 0, 60) . '…' : $preview) ?></span>
<?php else: ?>
<span class="fhb-block-empty">— vide —</span>
<?php endif; ?>