Files
xamxam/app/migrations/applied/016_merge_banners_into_covers.php
Pontoporeia 021c58925e fix: auto-regenerate thesis identifier on any year-prefix mismatch, support .php migrations in runner
ThesisEditController::save() previously only regenerated the identifier when
the year field changed during an edit. If a thesis had its year corrected in
a past edit (or via other means) and the identifier still carried the old
year prefix, subsequent edits that didn't touch the year field would leave
the mismatched identifier in place.

Now saves() also checks whether the existing identifier's 4-digit prefix
matches the thesis year, and regenerates if not — regardless of whether year
changed in the current edit.

The migration runner (run.php) only scanned for .sql files, so PHP migrations
(013, 016, 018, 038) were never auto-applied. Extended the runner to also
discover and execute .php migrations in a subprocess. If a PHP migration fails
with an idempotent error (no such column, already exists, duplicate column),
the runner treats it as already-applied and continues rather than aborting
— preventing a stale migration like 016 (banner_path already dropped by 028)
from blocking migrations that come after it alphabetically (e.g. 038).

Updated migrations 016 and 038 to accept an optional $argv[1] DB path.
Fixed 016 to gracefully handle the banner_path column already being gone
(exit 0 instead of fatal).
2026-06-10 00:17:30 +02:00

140 lines
5.0 KiB
PHP

<?php
/**
* Migration 016 — merge banners into covers
*
* 1. For every thesis that has a banner_path:
* a. Copy the file from storage/banners/<file> to storage/covers/<file>
* b. Insert a thesis_files row with file_type='cover'
* c. Clear theses.banner_path
* 2. Remove the now-empty storage/banners/ directory (best-effort).
*
* Safe to re-run: if a cover record already exists for a thesis, the banner
* migration for that thesis is skipped.
*/
defined('APP_ROOT') || define('APP_ROOT', dirname(__DIR__, 2));
defined('STORAGE_ROOT') || define('STORAGE_ROOT', APP_ROOT . '/storage');
// Accept optional DB path from command line (used by run.php runner)
$dbPath = $argv[1] ?? (APP_ROOT . '/storage/xamxam.db');
if (!file_exists($dbPath)) {
echo "ERROR: database not found at $dbPath\n";
exit(1);
}
$pdo = new PDO('sqlite:' . $dbPath);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$pdo->exec('PRAGMA foreign_keys = ON');
$coverDir = STORAGE_ROOT . '/covers/';
$bannerDir = STORAGE_ROOT . '/banners/';
if (!is_dir($coverDir)) {
mkdir($coverDir, 0755, true);
echo "Created covers/ directory.\n";
}
// Detect if banner_path column still exists (dropped by migration 028)
try {
$stmt = $pdo->query("SELECT id, banner_path FROM theses WHERE banner_path IS NOT NULL");
$rows = $stmt->fetchAll();
} catch (\PDOException $e) {
if (stripos($e->getMessage(), 'no such column') !== false) {
echo "banner_path column already removed — migration 016 already applied, nothing to do.\n";
exit(0);
}
throw $e;
}
if (empty($rows)) {
echo "No banners to migrate.\n";
} else {
foreach ($rows as $row) {
$thesisId = (int)$row['id'];
$bannerPath = $row['banner_path']; // e.g. "banners/abc123.png"
// Skip if a cover record already exists for this thesis
$check = $pdo->prepare("SELECT id FROM thesis_files WHERE thesis_id = ? AND file_type = 'cover' LIMIT 1");
$check->execute([$thesisId]);
if ($check->fetch()) {
echo " Thesis $thesisId: cover record already exists — skipping banner migration.\n";
// Still clear banner_path so UI stays clean
$pdo->prepare("UPDATE theses SET banner_path = NULL WHERE id = ?")->execute([$thesisId]);
continue;
}
$srcAbs = STORAGE_ROOT . '/' . $bannerPath;
$filename = basename($bannerPath);
$dstAbs = $coverDir . $filename;
$dstRel = 'covers/' . $filename;
if (!file_exists($srcAbs)) {
echo " Thesis $thesisId: source file missing ($srcAbs) — inserting DB record with new path anyway, skipping file copy.\n";
} else {
if (!copy($srcAbs, $dstAbs)) {
echo " ERROR: could not copy $srcAbs$dstAbs — skipping thesis $thesisId.\n";
continue;
}
chmod($dstAbs, 0644);
echo " Thesis $thesisId: copied $bannerPath$dstRel\n";
}
// Determine MIME from extension
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mime = match($ext) {
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
default => 'image/jpeg',
};
// Get file size
$size = file_exists($dstAbs) ? filesize($dstAbs) : 0;
// Insert cover record
$ins = $pdo->prepare(
"INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type, sort_order)
VALUES (?, 'cover', ?, ?, ?, ?, 0)"
);
$ins->execute([$thesisId, $dstRel, $filename, $size, $mime]);
echo " Thesis $thesisId: inserted cover record → $dstRel\n";
// Clear banner_path
$pdo->prepare("UPDATE theses SET banner_path = NULL WHERE id = ?")->execute([$thesisId]);
echo " Thesis $thesisId: cleared banner_path.\n";
}
}
// Remove old banner files that were successfully copied
$remaining = glob($bannerDir . '*') ?: [];
$allClear = true;
foreach ($remaining as $f) {
$basename = basename($f);
if (file_exists($coverDir . $basename)) {
@unlink($f);
echo "Removed migrated banner file: banners/$basename\n";
} else {
echo "WARNING: banners/$basename has no corresponding cover — leaving in place.\n";
$allClear = false;
}
}
// Remove the now-empty banners/ directory (best-effort, ignoring .gitkeep)
if ($allClear && is_dir($bannerDir)) {
$leftovers = array_diff(scandir($bannerDir), ['.', '..', '.gitkeep']);
if (empty($leftovers)) {
// Remove .gitkeep if present, then the dir
$gitkeep = $bannerDir . '.gitkeep';
if (file_exists($gitkeep)) {
@unlink($gitkeep);
}
@rmdir($bannerDir);
echo "Removed banners/ directory.\n";
} else {
echo "WARNING: banners/ directory still has files after migration — leaving in place.\n";
}
}
echo "\nMigration 016 complete.\n";