feat : wizard multi-étapes, migrations contenu, versionnage semver (v1.2.1) #60

Merged
cedricAbonnel merged 2 commits from feat/wizard-multi-step into main 2026-05-14 21:17:20 +00:00
15 changed files with 585 additions and 237 deletions
+74 -125
View File
@@ -1,140 +1,89 @@
# Changelog — varlog # Changelog
## [Unreleased] — 2026-05-13 Toutes les modifications notables sont documentées ici.
Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnage [semver](https://semver.org/lang/fr/).
### Performances ---
- **Cache multi-niveaux pour les vues d'articles** : temps de chargement réduit ## [Unreleased]
de +5 s à ~0,4 s sur 1 062 articles.
- Mémoïsation de `getAll()` et `getSearchIndex()` dans la requête PHP ---
(`$allCache`, `$searchIndexCache`) — évite les appels répétés.
- Cache disque par article (`_cache/articles/{uuid}.json`) avec invalidation ## [1.2.1] - 2026-05-14
par comparaison `mtime` — 1 lecture au lieu de 2 par article.
- Slug index (`_cache/slug_index.json`) : `getBySlug()` en O(1) sans scanner
tous les articles ; construit depuis `search_index.json` en un seul fichier.
- `getCategories()` et `$_allPublished` chargés depuis `search_index.json`
au lieu de `getAll()` — 1 fichier lu quelle que soit la taille du catalogue.
- `search_index.json` enrichi avec `cover`, `created_at`, `author` ; rebuild
automatique si le format est obsolète.
- `SearchEngine::scorePool()` : tokenise chaque article une seule fois pour
N mots de titre (vs N passes séparées qui retokenisaient chaque article
N fois et calculaient la similarité trigramme sur le contenu).
- Le nombre de lectures de fichiers par vue d'article est désormais constant
(~4), indépendamment du nombre total d'articles.
- Documentation : `docs/cache-architecture.md`.
### Corrigé ### Corrigé
- Cache article invalidé si `index.md` est plus récent que `meta.json` (migration de contenu ne se reflétait pas)
- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement. - Migration 001 : `touch(meta.json)` après écriture de `index.md` pour invalider le cache
Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans - `post_view.php` : le `# Titre` Markdown est retiré du rendu (déjà affiché par le template)
`/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`. - Wizard étape 1 : en-tête affiche « Modifier » sans répéter le titre de l'article
Le handler `add_files` détecte désormais le dépassement et affiche un message - `wizard.js` : suppression de `scrollToCursor` (calcul erroné sur textarea auto-resize) ; Ctrl+Home / Ctrl+End scrollent correctement via `scrollIntoView`
d'erreur explicite au lieu de rediriger sans rien faire.
### Fonctionnalités
- **Réactions visiteurs** : trois boutons (👍 Utile / 🔥 Important / 🤔 À creuser)
affichés sous chaque article. Toggle : recliquer retire la réaction. Accessible sans
compte via un cookie UUID (`vl_vid`, 1 an, `HttpOnly`). Comportement async fetch avec
fallback formulaire natif (compatible CSP `script-src 'self'`). Routes :
`POST /react`. Table BDD : `article_reactions`.
- **Commentaires avec vérification email** : formulaire nom + email (non publié) +
texte (2 000 caractères max). Protection honeypot + CSRF en session. Un code à
6 chiffres est envoyé par email (expire 24 h) ; le commentaire est auto-publié au clic
sur le lien de confirmation. Routes : `POST /comment`,
`GET /verify-comment/<6chiffres>`. Table BDD : `comments`.
- **Modération commentaires** : onglet **Commentaires** dans `/admin/comments` listant
tous les commentaires avec statut (vérifié / publié) et actions masquer/republier.
Route : `POST /comment-moderate`.
- **Page de confirmation à l'enregistrement** : cliquer sur "Enregistrer" affiche une
page intermédiaire avec le diff du contenu, le slug (déplacé ici depuis le formulaire,
avec suggestion auto si le titre a changé), un commentaire de révision pré-rempli
d'après les modifications détectées, et un aperçu SEO (snippet Google). La
sauvegarde effective n'a lieu qu'après confirmation.
- **URLs propres** : toutes les routes internes migrent vers des chemins lisibles.
Les anciennes URLs `/?action=…` restent fonctionnelles (compatibilité).
| Ancienne URL | Nouvelle URL |
|---|---|
| `/?action=edit&uuid=<u>` | `/edit/<u>` |
| `/?action=sources&uuid=<u>` | `/sources/<u>` |
| `/?action=diff&uuid=<u>&rev=<n>` | `/diff/<u>/<n>` |
| `/?action=create` | `/new` |
| `/?action=admin[&tab=<t>]` | `/admin[/<t>]` |
| `/?action=categories` | `/categories` |
| `/?action=profile` | `/profile` |
| `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… |
| `/?action=regen_thumbs` | `/admin/regen-thumbs` |
| `/?action=add_files&uuid=<u>` | `/files/<u>/add` |
| `/?action=import_image&uuid=<u>` | `/import/<u>` |
| `/?cat=<cat>` | `/categorie/<cat>` |
| `/?cursor=<uuid>` | `/page/<uuid>` |
- **Moteur de recherche** : index trigram+substring pré-construit (`search_index.json`,
reconstruit à chaque écriture), accessible depuis la navbar.
### Corrections
- **Métadonnées fichiers (sources)** : `addFileMeta()` ne sauvegardait pas l'auteur et
l'URL source en raison d'un guard `file_exists()` trop strict — supprimé.
- **Authentification OIDC** (`State invalide.`) : `session_start()` était appelé avant
`bootstrap.php` dans les fichiers OIDC, écrasant les paramètres de cookie
(`SameSite=Lax`, `Secure`, `HttpOnly`) — corrigé dans `start.php`, `callback.php`
et `me.php`.
- **Sidebar droite de l'article** : classe Bootstrap `flex-nowrap-lg` inexistante,
remplacée par `flex-lg-nowrap` — la sidebar ne tombe plus en bas de page.
- **Date d'affichage en liste** : `created_at` affiché à la place de `published_at`
— corrigé avec fallback approprié.
- **Formulaire d'édition** : "Fichiers existants" déplacé dans la colonne de droite ;
attribution auteur/source étendue à tous les types de fichiers (pas seulement images).
- **Historique des révisions** : plus de révision créée si le contenu et le titre
sont inchangés. Ajout des boutons de suppression par révision et suppression globale.
- **Canonical URL catégorie** : passe de `/?cat=…` à `/categorie/…`.
- **Flux RSS** : `/rss` et `/rss.xml` redirigent en 301 vers `/feed` (URL
canonique) ; les articles des catégories privées sont exclus du flux ;
la description est convertie depuis Markdown en texte brut.
--- ---
## 2026-05-09 ## [1.2.0] - 2026-05-14
### Fonctionnalis ### Ajou
- Wizard multi-étapes pour la création (5 écrans) et l'édition (6 écrans) d'articles (#58)
- Auto-sauvegarde en brouillon (debounce 3 s) avec indicateur visible
- Étape tags : champ plat avec détection automatique depuis le texte (abréviations, CamelCase, noms propres)
- Étape SEO : aperçu moteur de recherche en temps réel
- Étape 6 (édition) : diff ligne à ligne avant confirmation
- Plan Markdown dynamique (TOC) dans la colonne droite de l'éditeur
- Titre extrait du premier `# …` du contenu Markdown (plus de champ titre séparé)
- Système de migrations de contenu (`scripts/migrate_content.php`)
- Mode maintenance automatique (`data/.maintenance` → page HTTP 503)
- Migration `001` : ajout du titre Markdown dans les articles existants
- Bouton "Mettre à jour" dans l'administration (sans accès CLI)
- `UpdateChecker` : détection de mise à jour et migrations en attente
- Bandeau d'alerte pour les administrateurs sur toutes les pages
- Dashboard `/admin` : version déployée vs version disponible
- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` / ### Modifié
`WebSite`), `noindex` sur les pages d'administration. - `ArticleManager` : +6 méthodes pour les brouillons overlay
- **Recherche** : page de résultats avec score de pertinence, mise en évidence des - `lineDiff` : normalisation `\r\n``\n`, seuil relevé à 2 000 000, fallback ligne par ligne
termes, lien vers la catégorie depuis les résultats. - `push.sh` : génère `public/version.txt` (numéro de version semver) à chaque release
- **Support HEIC/HEIF** : conversion automatique en JPEG à l'upload.
- **Support SVG** : upload autorisé, servi avec Content-Type correct.
- **Avant-première** : article visible en liste mais verrouillé avant sa date de
publication.
- **Pagination curseur** : navigation par UUID de dernier article vu, sans offset SQL.
- **Layout article 3 colonnes** : sidebar gauche (catégorie), contenu central,
sidebar droite (pièces jointes, liens externes, articles liés).
- **Import depuis URL** : téléchargement de fichiers distants avec extraction
automatique des métadonnées (EXIF, OpenGraph, PDF).
- **Gestion des pièces jointes** dans le formulaire d'édition, avec attribution
auteur/source affichée dans la vue article.
### Corrections ### Corrigé
- Diff étape 6 "violent" (tout supprimé/ajouté) dû aux fins de ligne `\r\n` du navigateur
- Login intégré dans `layout.php`, chemins CSS en absolu.
- Redéclaration de `url()` dans `config.php` — fatal error corrigée.
- Correction permissions `www-data` sur `data/`.
--- ---
## 2026-04 et antérieur ## [1.1.0] - 2026-05-13
- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery. ### Ajouté
- Stockage des articles en fichiers Markdown (migration depuis base de données). - **Réactions visiteurs** : boutons 👍 / 🔥 / 🤔 sous chaque article, toggle async avec fallback formulaire natif
- SSO via Keycloak/OIDC avec PKCE. - **Commentaires avec vérification email** : code 6 chiffres, honeypot + CSRF, modération dans `/admin`
- Images de couverture (liste, vue article, `og:image`). - **URLs propres** : `/edit/<u>`, `/new`, `/admin`, `/categorie/<cat>`, `/files/<u>/add`, `/import/<u>`, etc.
- Brouillons visibles uniquement par l'auteur. - **Moteur de recherche** : index trigramme+substring pré-construit, résultats scorés avec mise en évidence
- Formulaire de contact (CSRF, honeypot, rate-limit).
- Pages : mentions légales (LCEN/RGPD), licences, à propos. ### Amélioré
- Auto-hébergement Bootstrap 5, police Inter, favicon SVG. - **Cache multi-niveaux** : chargement réduit de ~5 s à ~0,4 s sur 1 000+ articles (mémoïsation, cache disque, slug index O(1))
- Headers HTTP de sécurité, CSP stricte. - **Upload fichiers** : détection et message d'erreur explicite pour les fichiers > limite PHP
### Corrigé
- Métadonnées fichiers (`addFileMeta`) : guard `file_exists()` trop strict supprimé
- Sidebar droite article : classe Bootstrap `flex-nowrap-lg``flex-lg-nowrap`
- Flux RSS : exclusion catégories privées, redirection 301 `/rss``/feed`
---
## [1.0.0] - 2026-05-09
### Ajouté
- Moteur de blog PHP Folio — première release versionnée
- Articles en Markdown avec fichiers attachés, liens externes, images de couverture
- Authentification par lien magique envoyé par email (#29)
- SSO via Keycloak/OIDC avec PKCE
- Rôles, capacités et gestion des utilisateurs
- Catégories avec swatches couleur générées algorithmiquement
- Tags par type avec suggestions
- SEO : canonical, `sitemap.xml`, `robots.txt`, JSON-LD, `og:image`
- Avant-premières (articles futurs visibles aux utilisateurs autorisés)
- Pagination curseur (sans offset SQL)
- Import depuis URL (EXIF, OpenGraph, PDF)
- Historique des révisions avec diff
- Flux RSS (`/feed`) paginé avec autodiscovery
- Formulaire de contact (CSRF, honeypot, rate-limit)
- Pages légales (LCEN/RGPD), licences, à propos
- Migrations SQL versionnées (`database/migrate.php`)
- Système de déploiement par rsync
+17 -53
View File
@@ -13,20 +13,13 @@ document.addEventListener('DOMContentLoaded', function () {
ta.addEventListener('input', resizeTa); ta.addEventListener('input', resizeTa);
resizeTa(); resizeTa();
function scrollToCursor() { // Ctrl+Home / Ctrl+End : scroller la fenêtre vers le début/fin du textarea
var lineH = parseFloat(getComputedStyle(ta).lineHeight) || 20; ta.addEventListener('keydown', function (e) {
var padT = parseFloat(getComputedStyle(ta).paddingTop) || 8; if (!(e.ctrlKey || e.metaKey) || (e.key !== 'Home' && e.key !== 'End')) return;
var lines = ta.value.substr(0, ta.selectionStart).split('\n').length; requestAnimationFrame(function () {
var cursorY = ta.getBoundingClientRect().top + padT + lines * lineH; ta.scrollIntoView({ block: e.key === 'Home' ? 'start' : 'end', behavior: 'smooth' });
var margin = lineH * 3; });
if (cursorY > window.innerHeight - margin) { });
window.scrollBy({ top: cursorY - window.innerHeight + margin, behavior: 'instant' });
} else if (cursorY < margin) {
window.scrollBy({ top: cursorY - margin, behavior: 'instant' });
}
}
ta.addEventListener('keyup', scrollToCursor);
ta.addEventListener('click', scrollToCursor);
} }
// ─── Ctrl+Enter soumet le formulaire ──────────────────────────────────── // ─── Ctrl+Enter soumet le formulaire ────────────────────────────────────
@@ -37,40 +30,12 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
// ─── Génération slug automatique (étape 1 / création) ─────────────────── var slugField = document.getElementById('slug');
var titleInput = document.getElementById('wz-title');
var slugField = document.getElementById('slug');
var slugPreview = document.getElementById('slug-preview');
function slugify(s) {
var map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
return s.toLowerCase()
.replace(/[àâäéèêëîïôöùûüçæœ]/g, function(c) { return map[c] || c; })
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (titleInput && slugField) {
if (slugField.value !== '') slugField._auto = false;
titleInput.addEventListener('input', function () {
if (slugField._auto !== false) {
var gen = slugify(this.value);
slugField.value = gen;
if (slugPreview) slugPreview.textContent = gen;
}
});
slugField.addEventListener('input', function () {
this._auto = (this.value === '');
if (slugPreview) slugPreview.textContent = this.value;
});
}
// ─── Autosave ──────────────────────────────────────────────────────────── // ─── Autosave ────────────────────────────────────────────────────────────
var indicator = document.getElementById('autosave-indicator'); var indicator = document.getElementById('autosave-indicator');
if (indicator && uuid && autosaveUrl) { if (indicator && uuid && autosaveUrl) {
var timer = null; var timer = null;
var titleEl = document.getElementById('wz-title');
var contentEl = document.getElementById('wz-content');
function scheduleAutosave() { function scheduleAutosave() {
clearTimeout(timer); clearTimeout(timer);
@@ -78,27 +43,26 @@ document.addEventListener('DOMContentLoaded', function () {
} }
async function doAutosave() { async function doAutosave() {
if (!titleEl || !contentEl) return; if (!ta) return;
indicator.textContent = 'Sauvegarde…'; indicator.textContent = 'Sauvegarde…';
try { try {
var res = await fetch(autosaveUrl, { var res = await fetch(autosaveUrl, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ body: new URLSearchParams({ content: ta.value }),
title: titleEl.value,
content: contentEl.value,
slug: slugField ? slugField.value : '',
}),
}); });
var data = await res.json(); var data = await res.json();
indicator.textContent = data.ok ? 'Brouillon sauvegardé à ' + data.time : 'Erreur de sauvegarde'; if (data.ok) {
indicator.textContent = 'Sauvegardé à ' + data.time;
} else {
indicator.textContent = 'Erreur de sauvegarde';
}
} catch (err) { } catch (err) {
indicator.textContent = 'Erreur de sauvegarde'; indicator.textContent = 'Erreur de sauvegarde';
} }
} }
if (titleEl) titleEl.addEventListener('input', scheduleAutosave); if (ta) ta.addEventListener('input', scheduleAutosave);
if (ta) ta.addEventListener('input', scheduleAutosave);
} }
// ─── Insertion Markdown depuis miniatures ──────────────────────────────── // ─── Insertion Markdown depuis miniatures ────────────────────────────────
+75 -40
View File
@@ -26,6 +26,17 @@ require_once BASE_PATH . '/src/ArticleManager.php';
$articles = new ArticleManager(BASE_PATH . '/data'); $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'; $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? ''; $uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? ''; $slug = $_GET['slug'] ?? '';
@@ -512,28 +523,24 @@ switch ($action) {
case 1: case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? ''); $content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$content = $_POST['content'] ?? ''; $title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
$postSlug = trim($_POST['slug'] ?? ''); $postSlug = trim($_POST['slug'] ?? '');
if ($title === '') { if ($draft === null) {
$errors[] = 'Le titre est obligatoire.'; $uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
} else { foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
if ($draft === null) { if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []); $articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
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'); $_SESSION['wizard_create'] = $uuid;
exit; } 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'] ?? ''; $content = $draft['content'] ?? '';
$postSlug = $draft['slug'] ?? ''; $postSlug = $draft['slug'] ?? '';
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : []; $existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
@@ -865,15 +872,11 @@ switch ($action) {
case 1: case 1:
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? ''); $content = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$content = $_POST['content'] ?? ''; $title = extractMarkdownTitle($content) ?: ($draft['title'] ?? 'Sans titre');
if ($title === '') { $articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'] ?? '')], $content);
$errors[] = 'Le titre est obligatoire.'; header('Location: /edit/' . rawurlencode($uuid) . '/2');
} else { exit;
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'])], $content);
header('Location: /edit/' . rawurlencode($uuid) . '/2');
exit;
}
} }
$title = $draft['title']; $title = $draft['title'];
$content = $draft['content']; $content = $draft['content'];
@@ -1465,15 +1468,13 @@ switch ($action) {
echo json_encode(['ok' => false]); echo json_encode(['ok' => false]);
exit; exit;
} }
$asTitle = trim($_POST['title'] ?? ''); $asContent = str_replace("\r\n", "\n", $_POST['content'] ?? '');
$asContent = $_POST['content'] ?? '';
$asSlug = trim($_POST['slug'] ?? ''); $asSlug = trim($_POST['slug'] ?? '');
if ($asTitle === '') { $_asCurrent = $articles->getByUuid($uuid);
echo json_encode(['ok' => false]); $asTitle = extractMarkdownTitle($asContent) ?: ($_asCurrent['title'] ?? 'Sans titre');
exit;
}
$ok = $articles->autosave($uuid, $asTitle, $asContent, $asSlug); $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; exit;
case 'autosave_draft': case 'autosave_draft':
@@ -1488,14 +1489,12 @@ switch ($action) {
echo json_encode(['ok' => false]); echo json_encode(['ok' => false]);
exit; exit;
} }
$_adTitle = trim($_POST['title'] ?? ''); $_adContent = isset($_POST['content']) ? str_replace("\r\n", "\n", $_POST['content']) : null;
$_adContent = $_POST['content'] ?? null; $_adTitle = $_adContent !== null
if ($_adTitle === '') { ? (extractMarkdownTitle($_adContent) ?: ($_adArticle['title'] ?? 'Sans titre'))
echo json_encode(['ok' => false]); : ($_adArticle['title'] ?? 'Sans titre');
exit;
}
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent); $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; exit;
case 'edit_discard_draft': case 'edit_discard_draft':
@@ -2733,6 +2732,42 @@ switch ($action) {
header('Location: /admin/users'); header('Location: /admin/users');
exit; 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': case 'admin_save_site':
requireAuth(); requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+1
View File
@@ -0,0 +1 @@
1.2.1
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
// Migration 001 : préfixe chaque article avec `# Titre` si aucun titre
// Markdown de niveau 1 n'est déjà présent dans le contenu.
//
// Variables disponibles depuis le runner : $dataDir
/** @var string $dataDir */
$updated = 0;
$skipped = 0;
foreach (glob($dataDir . '/*/meta.json') as $metaPath) {
$dir = dirname($metaPath);
$mdPath = $dir . '/index.md';
if (!file_exists($mdPath)) {
$skipped++;
continue;
}
$meta = json_decode((string) file_get_contents($metaPath), true);
if (!is_array($meta)) {
$skipped++;
continue;
}
$title = trim($meta['title'] ?? '');
$content = (string) file_get_contents($mdPath);
$content = str_replace("\r\n", "\n", $content);
// Déjà un titre Markdown niveau 1 → on ne touche pas
if (preg_match('/^\s*#\s+\S/', $content)) {
$skipped++;
continue;
}
if ($title === '') {
$skipped++;
continue;
}
file_put_contents($mdPath, '# ' . $title . "\n\n" . ltrim($content));
touch($metaPath);
$updated++;
}
echo " $updated article(s) mis à jour, $skipped ignoré(s)\n";
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
// Runner de migrations de contenu (fichiers articles).
// Analogie avec database/migrate.php, mais pour les fichiers data/.
//
// Usage : php scripts/migrate_content.php [/chemin/vers/data]
//
// - Lit data/.content_migrations.json pour savoir ce qui a déjà tourné.
// - Active data/.maintenance pendant l'exécution (→ page 503 aux visiteurs).
// - Applique les scripts scripts/content/migration_*.php non encore appliqués.
$baseDir = dirname(__DIR__);
$dataDir = $argv[1] ?? ($baseDir . '/data');
if (!is_dir($dataDir)) {
fwrite(STDERR, "Répertoire data introuvable : $dataDir\n");
exit(1);
}
$trackFile = $dataDir . '/.content_migrations.json';
$maintenanceFlag = $dataDir . '/.maintenance';
$applied = [];
if (file_exists($trackFile)) {
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
}
$files = glob(__DIR__ . '/content/migration_*.php') ?: [];
sort($files);
$pending = array_values(array_filter($files, fn ($f) => !isset($applied[basename($f)])));
if (empty($pending)) {
echo " (aucune migration de contenu en attente)\n";
exit(0);
}
file_put_contents($maintenanceFlag, date('Y-m-d H:i:s'));
echo "→ Mode maintenance activé\n";
$count = 0;
$errors = 0;
foreach ($pending as $file) {
$name = basename($file);
echo "$name ... ";
try {
require $file;
$applied[$name] = date('Y-m-d H:i:s');
file_put_contents(
$trackFile,
json_encode($applied, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
);
echo "\n";
$count++;
} catch (Throwable $e) {
echo "" . $e->getMessage() . "\n";
$errors++;
break;
}
}
if (file_exists($maintenanceFlag)) {
unlink($maintenanceFlag);
}
echo "→ Mode maintenance désactivé\n";
echo "$count migration(s) appliquée(s)" . ($errors ? ", $errors erreur(s)" : '') . ".\n";
exit($errors > 0 ? 1 : 0);
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Pousse le code Folio vers git.abonnel.fr/cedricAbonnel/folio
# Usage : ./scripts/push.sh "message de commit"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$SCRIPT_DIR/.."
MSG="${1:-}"
if [[ -z "$MSG" ]]; then
echo "Usage: $0 \"message de commit\""
exit 1
fi
cd "$ROOT"
if [ ! -d .git ]; then
git init -b main
git remote add origin https://git.abonnel.fr/cedricAbonnel/folio.git
echo "→ Dépôt git initialisé"
fi
# Extraire la version depuis CHANGELOG.md (première entrée ## [X.Y.Z])
FOLIO_VERSION=$(grep -m1 '^\#\# \[[0-9]' CHANGELOG.md | sed 's/.*\[\([^]]*\)\].*/\1/')
if [[ -z "$FOLIO_VERSION" ]]; then
echo "✗ Impossible d'extraire la version depuis CHANGELOG.md"
exit 1
fi
echo "$FOLIO_VERSION" > public/version.txt
echo "→ Version : $FOLIO_VERSION"
git add -A
git diff --cached --quiet && echo "(rien à committer)" && exit 0
git commit -m "$MSG"
git push origin main
echo "✓ Poussé vers folio"
+3 -2
View File
@@ -1164,8 +1164,9 @@ class ArticleManager
$uuid = basename($dir); $uuid = basename($dir);
$cachePath = $this->articleCachePath($uuid); $cachePath = $this->articleCachePath($uuid);
// Utiliser le cache si plus récent que meta.json // Utiliser le cache si plus récent que meta.json ET index.md
if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath)) { $contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0;
if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) {
$cached = json_decode((string) file_get_contents($cachePath), true); $cached = json_decode((string) file_get_contents($cachePath), true);
if (is_array($cached) && !empty($cached['uuid'])) { if (is_array($cached) && !empty($cached['uuid'])) {
return $cached; return $cached;
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* Vérifie si une mise à jour de Folio est disponible sur le dépôt Git,
* et si des migrations de contenu sont en attente.
*
* Aucune dépendance externe : utilise file_get_contents() + cache JSON.
*/
class UpdateChecker
{
private string $dataDir;
private string $baseDir;
public function __construct(string $dataDir, string $baseDir)
{
$this->dataDir = $dataDir;
$this->baseDir = $baseDir;
}
/** Retourne la liste des alertes à afficher aux administrateurs. */
public function adminNotices(): array
{
$notices = [];
if ($this->hasPendingContentMigrations()) {
$notices[] = [
'type' => 'warning',
'message' => 'Des migrations de contenu sont en attente.',
];
}
$update = $this->checkRemoteVersion();
if ($update !== null) {
$notices[] = [
'type' => 'info',
'message' => 'Une nouvelle version de Folio est disponible : <strong>v' . htmlspecialchars($update) . '</strong>.',
];
}
return $notices;
}
// ─── Migrations de contenu en attente ────────────────────────────────────
private function hasPendingContentMigrations(): bool
{
$trackFile = $this->dataDir . '/.content_migrations.json';
$applied = [];
if (file_exists($trackFile)) {
$applied = json_decode((string) file_get_contents($trackFile), true) ?? [];
}
foreach (glob($this->baseDir . '/scripts/content/migration_*.php') ?: [] as $f) {
if (!isset($applied[basename($f)])) {
return true;
}
}
return false;
}
// ─── Vérification version distante (Gitea) ───────────────────────────────
/**
* Retourne le numéro de la version disponible si elle est supérieure
* à la version déployée, null sinon.
*/
private function checkRemoteVersion(): ?string
{
$repoUrl = rtrim((string) ($_ENV['FOLIO_REPO_URL'] ?? getenv('FOLIO_REPO_URL') ?: ''), '/');
if ($repoUrl === '') {
return null;
}
$deployedFile = $this->baseDir . '/public/version.txt';
if (!file_exists($deployedFile)) {
return null;
}
$deployedVer = trim((string) file_get_contents($deployedFile));
if ($deployedVer === '' || !preg_match('/^\d+\.\d+\.\d+/', $deployedVer)) {
return null;
}
$remoteVer = $this->fetchRemoteVersion($repoUrl);
if ($remoteVer === null) {
return null;
}
return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null;
}
/**
* Récupère `public/version.txt` depuis le dépôt Gitea (branche main).
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
*/
private function fetchRemoteVersion(string $repoUrl): ?string
{
$cacheFile = $this->dataDir . '/.version_check_cache.json';
$ttl = 3600;
if (file_exists($cacheFile)) {
$cache = json_decode((string) file_get_contents($cacheFile), true) ?? [];
if (isset($cache['fetched_at'], $cache['version'])
&& (time() - (int) $cache['fetched_at']) < $ttl
) {
return (string) $cache['version'];
}
}
// URL du fichier brut : {repo}/raw/branch/main/public/version.txt
$rawUrl = $repoUrl . '/raw/branch/main/public/version.txt';
$token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: '');
$opts = [
'http' => [
'timeout' => 5,
'header' => $token !== '' ? "Authorization: token $token" : '',
],
];
$body = @file_get_contents($rawUrl, false, stream_context_create($opts));
if ($body === false) {
return null;
}
$version = trim($body);
if (!preg_match('/^\d+\.\d+\.\d+/', $version)) {
return null;
}
file_put_contents(
$cacheFile,
json_encode(['fetched_at' => time(), 'version' => $version]) . "\n"
);
return $version;
}
}
+22 -6
View File
@@ -19,19 +19,35 @@ function slugify(string $s): string
return trim($s, '-'); return trim($s, '-');
} }
/** Extrait le titre depuis le premier titre Markdown `# ...` du contenu. */
function extractMarkdownTitle(string $content): string
{
foreach (explode("\n", str_replace("\r\n", "\n", $content)) as $line) {
if (preg_match('/^#\s+(.+)/', rtrim($line), $m)) {
return trim($m[1]);
}
}
return '';
}
/** /**
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où * Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté). * op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
*/ */
function lineDiff(string $old, string $new): array function lineDiff(string $old, string $new): array
{ {
$a = explode("\n", $old); $old = str_replace("\r\n", "\n", $old);
$b = explode("\n", $new); $new = str_replace("\r\n", "\n", $new);
$n = count($a); $a = explode("\n", $old);
$m = count($b); $b = explode("\n", $new);
$n = count($a);
$m = count($b);
if ($n * $m > 300000) { if ($n * $m > 2_000_000) {
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]]; $diff = [['!', "Diff trop grand ({$n}×{$m} lignes) affichage simplifié."]];
foreach ($a as $line) { $diff[] = ['-', $line]; }
foreach ($b as $line) { $diff[] = ['+', $line]; }
return $diff;
} }
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0)); $dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
+49
View File
@@ -91,6 +91,55 @@ function adminStatusBadge(array $a, int $now): string
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<!-- Version Folio ──────────────────────────────────────────────────────── -->
<?php
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
$_deployedLabel = $_deployedVer !== '' ? date('d/m/Y H:i', strtotime($_deployedVer)) : '—';
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
$_remoteLabel = '—';
foreach ($_notices as $_n) {
if ($_n['type'] === 'info' && preg_match('/publiée le ([^)]+)/', $_n['message'], $_m)) {
$_remoteLabel = $_m[1];
}
}
?>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Moteur Folio</div>
<div class="card-body py-2">
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap" style="width:160px">Version déployée</th>
<td><?= htmlspecialchars($_deployedLabel) ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th>
<td><?= htmlspecialchars($_remoteLabel) ?><?= $_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel ? ' <span class="badge bg-warning text-dark ms-1">Mise à jour disponible</span>' : '' ?></td>
</tr>
<?php if (!empty($_notices)): ?>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 align-top">Actions requises</th>
<td class="d-flex flex-wrap gap-2 align-items-center">
<?php foreach ($_notices as $_n): ?>
<?php if ($_n['type'] === 'warning'): ?>
<form method="POST" action="/?action=run_content_migrations">
<button type="submit" class="btn btn-warning btn-sm">Mettre à jour le contenu</button>
</form>
<?php endif; ?>
<?php endforeach; ?>
</td>
</tr>
<?php endif; ?>
<?php if (($_GET['notice'] ?? '') === 'migrated'): ?>
<tr><td colspan="2"><div class="alert alert-success py-1 mb-0 small">Migrations appliquées avec succès.</div></td></tr>
<?php elseif (($_GET['notice'] ?? '') === 'migration_error'): ?>
<tr><td colspan="2"><div class="alert alert-danger py-1 mb-0 small">Une erreur est survenue pendant la migration.</div></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<h5>Activité récente</h5> <h5>Activité récente</h5>
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
<thead> <thead>
+19
View File
@@ -106,6 +106,25 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
</nav> </nav>
</header> </header>
<?php if (function_exists('isAdmin') && isAdmin() && isset($_updateChecker)):
$_adminNotices = $_updateChecker->adminNotices();
foreach ($_adminNotices as $_notice):
$_isWarning = $_notice['type'] === 'warning'; ?>
<div class="alert alert-<?= $_isWarning ? 'warning' : 'info' ?> alert-dismissible rounded-0 border-0 border-bottom py-2 mb-0 small" role="alert">
<div class="container-fluid d-flex align-items-center gap-2 flex-wrap">
<span><?= $_notice['message'] ?></span>
<?php if ($_isWarning): ?>
<form method="POST" action="/?action=run_content_migrations" class="d-inline">
<button type="submit" class="btn btn-warning btn-sm py-0">Mettre à jour</button>
</form>
<?php else: ?>
<a href="/admin?tab=dashboard" class="btn btn-outline-primary btn-sm py-0">Voir dans l'admin</a>
<?php endif; ?>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Fermer"></button>
</div>
</div>
<?php endforeach; endif; ?>
<main class="<?= htmlspecialchars($mainClass ?? 'container') ?>" role="main"> <main class="<?= htmlspecialchars($mainClass ?? 'container') ?>" role="main">
<?= $content ?> <?= $content ?>
</main> </main>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mise à jour en cours</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8f9fa;color:#212529}
.box{text-align:center;padding:2.5rem 2rem;max-width:420px}
.icon{font-size:2.5rem;margin-bottom:1rem}
h1{font-size:1.4rem;font-weight:600;margin-bottom:.6rem}
p{color:#6c757d;line-height:1.6}
</style>
</head>
<body>
<div class="box">
<div class="icon">⚙️</div>
<h1>Mise à jour en cours</h1>
<p>Le site est temporairement indisponible pendant une mise à jour.<br>Merci de réessayer dans quelques instants.</p>
</div>
</body>
</html>
+3 -1
View File
@@ -9,6 +9,8 @@ $_accentMap = [
]; ];
$_tocItems = []; $_tocItems = [];
$_tocSeen = []; $_tocSeen = [];
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
$_renderedContent = preg_replace_callback( $_renderedContent = preg_replace_callback(
'/<(h[23])>(.+?)<\/h[23]>/i', '/<(h[23])>(.+?)<\/h[23]>/i',
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) { function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
@@ -31,7 +33,7 @@ $_renderedContent = preg_replace_callback(
$_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id]; $_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id];
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>"; return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
}, },
$Parsedown->text($rawContent) $Parsedown->text($_rawForRender)
); );
ob_start(); ob_start();
+3 -10
View File
@@ -19,7 +19,7 @@ $_hasUuid = $_wizUuid !== '';
<!-- En-tête avec boutons ────────────────────────────────────────────────── --> <!-- En-tête avec boutons ────────────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap"> <div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<div> <div>
<h1 class="h4 mb-0"><?= $mode === 'create' ? 'Nouvel article' : htmlspecialchars('Modifier — ' . ($article['title'] ?? '')) ?></h1> <h1 class="h4 mb-0" id="wz-page-title"><?= $mode === 'create' ? 'Nouvel article' : 'Modifier' ?></h1>
<?php if ($_hasUuid): ?> <?php if ($_hasUuid): ?>
<span id="autosave-indicator" class="text-muted small"></span> <span id="autosave-indicator" class="text-muted small"></span>
<?php endif; ?> <?php endif; ?>
@@ -39,19 +39,12 @@ $_hasUuid = $_wizUuid !== '';
<div class="row g-3 align-items-start"> <div class="row g-3 align-items-start">
<div class="col-lg-9"> <div class="col-lg-9">
<!-- Titre ─────────────────────────────────────────────────────────────── -->
<div class="mb-3">
<label for="wz-title" class="form-label fw-semibold">Titre</label>
<input type="text" class="form-control form-control-lg" id="wz-title" name="title" required
value="<?= htmlspecialchars($title ?? '') ?>"
placeholder="Titre de l'article…">
</div>
<!-- Contenu ──────────────────────────────────────────────────────────── --> <!-- Contenu ──────────────────────────────────────────────────────────── -->
<div class="mb-3"> <div class="mb-3">
<label for="wz-content" class="form-label fw-semibold">Contenu <small class="text-muted fw-normal">(Markdown)</small></label> <label for="wz-content" class="form-label fw-semibold">Contenu <small class="text-muted fw-normal">(Markdown)</small></label>
<textarea class="form-control font-monospace" id="wz-content" name="content" rows="18" <textarea class="form-control font-monospace" id="wz-content" name="content" rows="18"
style="min-height:320px"><?= htmlspecialchars($content ?? '') ?></textarea> style="min-height:320px"
placeholder="# Titre de l'article&#10;&#10;Votre contenu ici…"><?= htmlspecialchars($content ?? '') ?></textarea>
</div> </div>
</div><!-- /col-lg-9 --> </div><!-- /col-lg-9 -->