feat : versionnage semver, migrations contenu, bandeau mise à jour admin

- CHANGELOG.md : structure semver (1.0.0 / 1.1.0 / 1.2.0) remplace le journal non versionné
- public/version.txt : généré à chaque push depuis la première entrée CHANGELOG
- scripts/push.sh : extrait la version CHANGELOG avant git add
- src/UpdateChecker.php : compare version déployée vs version Gitea (raw file), cache 1 h
- templates/layout.php : bandeau alerte admin (nouvelle version / migrations en attente)
- templates/admin.php : dashboard moteur Folio (version déployée / disponible)
- scripts/migrate_content.php + migration_001 : ajout # titre dans les articles existants
- templates/maintenance.php : page HTTP 503 pendant une migration
- src/helpers.php : extractMarkdownTitle(), normalisation \r\n dans lineDiff()
- templates/wizard/step1.php : suppression champ titre, plan TOC dynamique
- public/assets/js/wizard.js : scope titleEl, scrollToCursor, buildToc, handlers externalisés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:45:35 +02:00
parent c503f1dd66
commit 1dbe6d8dd3
13 changed files with 565 additions and 219 deletions
+75 -40
View File
@@ -26,6 +26,17 @@ require_once BASE_PATH . '/src/ArticleManager.php';
$articles = new ArticleManager(BASE_PATH . '/data');
// ─── Mode maintenance ──────────────────────────────────────────────────────
if (file_exists(BASE_PATH . '/data/.maintenance')) {
http_response_code(503);
header('Retry-After: 60');
include BASE_PATH . '/templates/maintenance.php';
exit;
}
require_once BASE_PATH . '/src/UpdateChecker.php';
$_updateChecker = new UpdateChecker(BASE_PATH . '/data', BASE_PATH);
$action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
@@ -512,28 +523,24 @@ switch ($action) {
case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$content = $_POST['content'] ?? '';
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
$postSlug = trim($_POST['slug'] ?? '');
if ($title === '') {
$errors[] = 'Le titre est obligatoire.';
} else {
if ($draft === null) {
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
}
if ($draft === null) {
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
}
$_SESSION['wizard_create'] = $uuid;
} else {
$articles->autosave($uuid, $title, $content, $postSlug);
}
header('Location: /new/' . rawurlencode($uuid) . '/2');
exit;
$_SESSION['wizard_create'] = $uuid;
} else {
$articles->autosave($uuid, $title, $content, $postSlug);
}
header('Location: /new/' . rawurlencode($uuid) . '/2');
exit;
}
$title = $draft['title'] ?? ($_POST['title'] ?? '');
$title = $draft['title'] ?? '';
$content = $draft['content'] ?? '';
$postSlug = $draft['slug'] ?? '';
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
@@ -865,15 +872,11 @@ switch ($action) {
case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$content = $_POST['content'] ?? '';
if ($title === '') {
$errors[] = 'Le titre est obligatoire.';
} else {
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'])], $content);
header('Location: /edit/' . rawurlencode($uuid) . '/2');
exit;
}
$content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'] ?? '')], $content);
header('Location: /edit/' . rawurlencode($uuid) . '/2');
exit;
}
$title = $draft['title'];
$content = $draft['content'];
@@ -1465,15 +1468,13 @@ switch ($action) {
echo json_encode(['ok' => false]);
exit;
}
$asTitle = trim($_POST['title'] ?? '');
$asContent = $_POST['content'] ?? '';
$asContent = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$asSlug = trim($_POST['slug'] ?? '');
if ($asTitle === '') {
echo json_encode(['ok' => false]);
exit;
}
$_asCurrent = $articles->getByUuid($uuid);
$asTitle = extractMarkdownTitle($asContent) ?: ($_asCurrent['title'] ?? 'Sans titre');
$ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug);
echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]);
$_asSlugFinal = $ok ? ($articles->getByUuid($uuid)['slug'] ?? '') : '';
echo json_encode(['ok' => $ok, 'time' => date('H:i:s'), 'title' => $asTitle, 'slug' => $_asSlugFinal]);
exit;
case 'autosave_draft':
@@ -1488,14 +1489,12 @@ switch ($action) {
echo json_encode(['ok' => false]);
exit;
}
$_adTitle = trim($_POST['title'] ?? '');
$_adContent = $_POST['content'] ?? null;
if ($_adTitle === '') {
echo json_encode(['ok' => false]);
exit;
}
$_adContent = isset($_POST['content']) ? str_replace("\r\n", "\n", $_POST['content']) : null;
$_adTitle = $_adContent !== null
? (extractMarkdownTitle($_adContent) ?: ($_adArticle['title'] ?? 'Sans titre'))
: ($_adArticle['title'] ?? 'Sans titre');
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent);
echo json_encode(['ok' => true, 'time' => date('H:i:s')]);
echo json_encode(['ok' => true, 'time' => date('H:i:s'), 'title' => $_adTitle]);
exit;
case 'edit_discard_draft':
@@ -2733,6 +2732,42 @@ switch ($action) {
header('Location: /admin/users');
exit;
case 'run_content_migrations':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$_cmDataDir = BASE_PATH . '/data';
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
$_cmFlag = $_cmDataDir . '/.maintenance';
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
$_cmFiles = glob(BASE_PATH . '/scripts/content/migration_*.php') ?: [];
sort($_cmFiles);
$_cmPending = array_values(array_filter($_cmFiles, fn ($f) => !isset($_cmApplied[basename($f)])));
if (empty($_cmPending)) {
header('Location: /admin?tab=dashboard&notice=no_migrations');
exit;
}
file_put_contents($_cmFlag, date('Y-m-d H:i:s'));
$_cmErrors = 0;
$dataDir = $_cmDataDir;
foreach ($_cmPending as $_cmFile) {
try {
require $_cmFile;
$_cmApplied[basename($_cmFile)] = date('Y-m-d H:i:s');
file_put_contents($_cmTrack, json_encode($_cmApplied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n");
} catch (Throwable $_cmEx) {
$_cmErrors++;
break;
}
}
if (file_exists($_cmFlag)) {
unlink($_cmFlag);
}
header('Location: /admin?tab=dashboard&notice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
exit;
case 'admin_save_site':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {