From 1dbe6d8dd35bed02c1971f3d6f6ee91b75344fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Thu, 14 May 2026 22:45:35 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20versionnage=20semver,=20migratio?= =?UTF-8?q?ns=20contenu,=20bandeau=20mise=20=C3=A0=20jour=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 188 ++++++------------ public/assets/js/wizard.js | 55 ++--- public/index.php | 115 +++++++---- public/version.txt | 1 + .../content/migration_001_add_h1_headings.php | 49 +++++ scripts/migrate_content.php | 72 +++++++ scripts/push.sh | 36 ++++ src/UpdateChecker.php | 138 +++++++++++++ src/helpers.php | 28 ++- templates/admin.php | 49 +++++ templates/layout.php | 19 ++ templates/maintenance.php | 23 +++ templates/wizard/step1.php | 11 +- 13 files changed, 565 insertions(+), 219 deletions(-) create mode 100644 public/version.txt create mode 100644 scripts/content/migration_001_add_h1_headings.php create mode 100644 scripts/migrate_content.php create mode 100755 scripts/push.sh create mode 100644 src/UpdateChecker.php create mode 100644 templates/maintenance.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1a914..0add525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,140 +1,78 @@ -# 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 - 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 - 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`. +## [Unreleased] + +--- + +## [1.2.0] - 2026-05-14 + +### Ajouté +- 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 + +### Modifié +- `ArticleManager` : +6 méthodes pour les brouillons overlay +- `lineDiff` : normalisation `\r\n` → `\n`, seuil relevé à 2 000 000, fallback ligne par ligne +- `push.sh` : génère `public/version.txt` (numéro de version semver) à chaque release ### Corrigé - -- **Upload de fichiers (#48)** : les fichiers > 8 Mo étaient rejetés silencieusement. - Le serveur utilise `mod_php` (non PHP-FPM) ; les limites ont été corrigées dans - `/etc/php/8.3/apache2/php.ini` : `upload_max_filesize = 500M`, `post_max_size = 2048M`. - Le handler `add_files` détecte désormais le dépassement et affiche un message - 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=` | `/edit/` | - | `/?action=sources&uuid=` | `/sources/` | - | `/?action=diff&uuid=&rev=` | `/diff//` | - | `/?action=create` | `/new` | - | `/?action=admin[&tab=]` | `/admin[/]` | - | `/?action=categories` | `/categories` | - | `/?action=profile` | `/profile` | - | `/?action=about\|legal\|licenses\|contact` | `/about`, `/legal`… | - | `/?action=regen_thumbs` | `/admin/regen-thumbs` | - | `/?action=add_files&uuid=` | `/files//add` | - | `/?action=import_image&uuid=` | `/import/` | - | `/?cat=` | `/categorie/` | - | `/?cursor=` | `/page/` | - -- **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. +- Diff étape 6 "violent" (tout supprimé/ajouté) dû aux fins de ligne `\r\n` du navigateur --- -## 2026-05-09 +## [1.1.0] - 2026-05-13 -### Fonctionnalités +### Ajouté +- **Réactions visiteurs** : boutons 👍 / 🔥 / 🤔 sous chaque article, toggle async avec fallback formulaire natif +- **Commentaires avec vérification email** : code 6 chiffres, honeypot + CSRF, modération dans `/admin` +- **URLs propres** : `/edit/`, `/new`, `/admin`, `/categorie/`, `/files//add`, `/import/`, etc. +- **Moteur de recherche** : index trigramme+substring pré-construit, résultats scorés avec mise en évidence -- **SEO** : balises canonical, `sitemap.xml`, `robots.txt`, JSON-LD (`BlogPosting` / - `WebSite`), `noindex` sur les pages d'administration. -- **Recherche** : page de résultats avec score de pertinence, mise en évidence des - termes, lien vers la catégorie depuis les résultats. -- **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. +### Amélioré +- **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)) +- **Upload fichiers** : détection et message d'erreur explicite pour les fichiers > limite PHP -### Corrections - -- 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/`. +### 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` --- -## 2026-04 et antérieur +## [1.0.0] - 2026-05-09 -- Flux RSS paginé (`/feed`, `/rss`, `/rss.xml`) avec autodiscovery. -- Stockage des articles en fichiers Markdown (migration depuis base de données). -- SSO via Keycloak/OIDC avec PKCE. -- Images de couverture (liste, vue article, `og:image`). -- Brouillons visibles uniquement par l'auteur. -- Formulaire de contact (CSRF, honeypot, rate-limit). -- Pages : mentions légales (LCEN/RGPD), licences, à propos. -- Auto-hébergement Bootstrap 5, police Inter, favicon SVG. -- Headers HTTP de sécurité, CSP stricte. +### 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 diff --git a/public/assets/js/wizard.js b/public/assets/js/wizard.js index 599277d..26454e1 100644 --- a/public/assets/js/wizard.js +++ b/public/assets/js/wizard.js @@ -37,40 +37,12 @@ document.addEventListener('DOMContentLoaded', function () { }); } - // ─── Génération slug automatique (étape 1 / création) ─────────────────── - 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; - }); - } + var slugField = document.getElementById('slug'); // ─── Autosave ──────────────────────────────────────────────────────────── var indicator = document.getElementById('autosave-indicator'); if (indicator && uuid && autosaveUrl) { - var timer = null; - var titleEl = document.getElementById('wz-title'); - var contentEl = document.getElementById('wz-content'); + var timer = null; function scheduleAutosave() { clearTimeout(timer); @@ -78,27 +50,32 @@ document.addEventListener('DOMContentLoaded', function () { } async function doAutosave() { - if (!titleEl || !contentEl) return; + if (!ta) return; indicator.textContent = 'Sauvegarde…'; try { var res = await fetch(autosaveUrl, { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: new URLSearchParams({ - title: titleEl.value, - content: contentEl.value, - slug: slugField ? slugField.value : '', - }), + body: new URLSearchParams({ content: ta.value }), }); var data = await res.json(); - indicator.textContent = data.ok ? 'Brouillon sauvegardé à ' + data.time : 'Erreur de sauvegarde'; + if (data.ok) { + indicator.textContent = 'Sauvegardé à ' + data.time; + // Mettre à jour le titre de la page si le serveur l'a extrait + var pageTitle = document.getElementById('wz-page-title'); + if (pageTitle && data.title) { + var prefix = pageTitle.dataset.prefix || ''; + pageTitle.textContent = prefix ? prefix + data.title : data.title; + } + } else { + indicator.textContent = 'Erreur de sauvegarde'; + } } catch (err) { 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 ──────────────────────────────── diff --git a/public/index.php b/public/index.php index 87e48f0..b5cfadd 100644 --- a/public/index.php +++ b/public/index.php @@ -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¬ice=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¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated')); + exit; + case 'admin_save_site': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { diff --git a/public/version.txt b/public/version.txt new file mode 100644 index 0000000..26aaba0 --- /dev/null +++ b/public/version.txt @@ -0,0 +1 @@ +1.2.0 diff --git a/scripts/content/migration_001_add_h1_headings.php b/scripts/content/migration_001_add_h1_headings.php new file mode 100644 index 0000000..2483b0c --- /dev/null +++ b/scripts/content/migration_001_add_h1_headings.php @@ -0,0 +1,49 @@ + !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); diff --git a/scripts/push.sh b/scripts/push.sh new file mode 100755 index 0000000..849305c --- /dev/null +++ b/scripts/push.sh @@ -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" diff --git a/src/UpdateChecker.php b/src/UpdateChecker.php new file mode 100644 index 0000000..d46643d --- /dev/null +++ b/src/UpdateChecker.php @@ -0,0 +1,138 @@ +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 : v' . htmlspecialchars($update) . '.', + ]; + } + + 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; + } +} diff --git a/src/helpers.php b/src/helpers.php index fc22b88..96e10d8 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -19,19 +19,35 @@ function slugify(string $s): string 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ù * op est '=' (inchangé), '-' (supprimé), '+' (ajouté). */ function lineDiff(string $old, string $new): array { - $a = explode("\n", $old); - $b = explode("\n", $new); - $n = count($a); - $m = count($b); + $old = str_replace("\r\n", "\n", $old); + $new = str_replace("\r\n", "\n", $new); + $a = explode("\n", $old); + $b = explode("\n", $new); + $n = count($a); + $m = count($b); - if ($n * $m > 300000) { - return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]]; + if ($n * $m > 2_000_000) { + $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)); diff --git a/templates/admin.php b/templates/admin.php index ed62010..67716ae 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -91,6 +91,55 @@ function adminStatusBadge(array $a, int $now): string + + adminNotices() : []; + $_remoteLabel = '—'; + foreach ($_notices as $_n) { + if ($_n['type'] === 'info' && preg_match('/publiée le ([^)]+)/', $_n['message'], $_m)) { + $_remoteLabel = $_m[1]; + } + } + ?> +
+
Moteur Folio
+
+ + + + + + + + + + + + + + + + + + + + + + +
Version déployée
Dernière version disponibleMise à jour disponible' : '' ?>
Actions requises + + +
+ +
+ + +
Migrations appliquées avec succès.
Une erreur est survenue pendant la migration.
+
+
+
Activité récente
diff --git a/templates/layout.php b/templates/layout.php index 1cdbee8..bc71e05 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -106,6 +106,25 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? ''); +adminNotices(); + foreach ($_adminNotices as $_notice): + $_isWarning = $_notice['type'] === 'warning'; ?> +
+
+ + +
+ + + + Voir dans l'admin + + +
+
+ +
diff --git a/templates/maintenance.php b/templates/maintenance.php new file mode 100644 index 0000000..2b7a50b --- /dev/null +++ b/templates/maintenance.php @@ -0,0 +1,23 @@ + + + + + + Mise à jour en cours + + + +
+
⚙️
+

Mise à jour en cours

+

Le site est temporairement indisponible pendant une mise à jour.
Merci de réessayer dans quelques instants.

+
+ + diff --git a/templates/wizard/step1.php b/templates/wizard/step1.php index 0341980..33d3ae4 100644 --- a/templates/wizard/step1.php +++ b/templates/wizard/step1.php @@ -19,7 +19,8 @@ $_hasUuid = $_wizUuid !== '';
-

+

@@ -39,14 +40,6 @@ $_hasUuid = $_wizUuid !== '';
- -
- - -
-
From 72cb7acae4fbec4918921a73f6f39d27683300d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Thu, 14 May 2026 23:07:15 +0200 Subject: [PATCH 2/2] fix 1.2.1 : cache index.md, H1 rendu, scroll wizard, titre Modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ArticleManager : invalider le cache si index.md est plus récent que meta.json - migration_001 : touch(meta.json) après maj index.md pour forcer l'invalidation - post_view.php : masquer le H1 initial du contenu (déjà affiché par le template) - step1.php : en-tête "Modifier" sans le titre de l'article - wizard.js : retirer scrollToCursor (erroné sur auto-resize) ; Ctrl+Home/End via scrollIntoView Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 ++++++++ public/assets/js/wizard.js | 27 +++++-------------- public/version.txt | 2 +- .../content/migration_001_add_h1_headings.php | 1 + src/ArticleManager.php | 5 ++-- templates/post_view.php | 4 ++- templates/wizard/step1.php | 6 ++--- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0add525..9960b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.2.1] - 2026-05-14 + +### Corrigé +- Cache article invalidé si `index.md` est plus récent que `meta.json` (migration de contenu ne se reflétait pas) +- Migration 001 : `touch(meta.json)` après écriture de `index.md` pour invalider le cache +- `post_view.php` : le `# Titre` Markdown est retiré du rendu (déjà affiché par le template) +- Wizard étape 1 : en-tête affiche « Modifier » sans répéter le titre de l'article +- `wizard.js` : suppression de `scrollToCursor` (calcul erroné sur textarea auto-resize) ; Ctrl+Home / Ctrl+End scrollent correctement via `scrollIntoView` + +--- + ## [1.2.0] - 2026-05-14 ### Ajouté diff --git a/public/assets/js/wizard.js b/public/assets/js/wizard.js index 26454e1..f926ea0 100644 --- a/public/assets/js/wizard.js +++ b/public/assets/js/wizard.js @@ -13,20 +13,13 @@ document.addEventListener('DOMContentLoaded', function () { ta.addEventListener('input', resizeTa); resizeTa(); - function scrollToCursor() { - var lineH = parseFloat(getComputedStyle(ta).lineHeight) || 20; - var padT = parseFloat(getComputedStyle(ta).paddingTop) || 8; - var lines = ta.value.substr(0, ta.selectionStart).split('\n').length; - var cursorY = ta.getBoundingClientRect().top + padT + lines * lineH; - 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+Home / Ctrl+End : scroller la fenêtre vers le début/fin du textarea + ta.addEventListener('keydown', function (e) { + if (!(e.ctrlKey || e.metaKey) || (e.key !== 'Home' && e.key !== 'End')) return; + requestAnimationFrame(function () { + ta.scrollIntoView({ block: e.key === 'Home' ? 'start' : 'end', behavior: 'smooth' }); + }); + }); } // ─── Ctrl+Enter soumet le formulaire ──────────────────────────────────── @@ -61,12 +54,6 @@ document.addEventListener('DOMContentLoaded', function () { var data = await res.json(); if (data.ok) { indicator.textContent = 'Sauvegardé à ' + data.time; - // Mettre à jour le titre de la page si le serveur l'a extrait - var pageTitle = document.getElementById('wz-page-title'); - if (pageTitle && data.title) { - var prefix = pageTitle.dataset.prefix || ''; - pageTitle.textContent = prefix ? prefix + data.title : data.title; - } } else { indicator.textContent = 'Erreur de sauvegarde'; } diff --git a/public/version.txt b/public/version.txt index 26aaba0..6085e94 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/scripts/content/migration_001_add_h1_headings.php b/scripts/content/migration_001_add_h1_headings.php index 2483b0c..ee4dde7 100644 --- a/scripts/content/migration_001_add_h1_headings.php +++ b/scripts/content/migration_001_add_h1_headings.php @@ -43,6 +43,7 @@ foreach (glob($dataDir . '/*/meta.json') as $metaPath) { } file_put_contents($mdPath, '# ' . $title . "\n\n" . ltrim($content)); + touch($metaPath); $updated++; } diff --git a/src/ArticleManager.php b/src/ArticleManager.php index 1ce4397..769e235 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -1164,8 +1164,9 @@ class ArticleManager $uuid = basename($dir); $cachePath = $this->articleCachePath($uuid); - // Utiliser le cache si plus récent que meta.json - if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath)) { + // Utiliser le cache si plus récent que meta.json ET index.md + $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); if (is_array($cached) && !empty($cached['uuid'])) { return $cached; diff --git a/templates/post_view.php b/templates/post_view.php index b14ef62..089f88d 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -9,6 +9,8 @@ $_accentMap = [ ]; $_tocItems = []; $_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( '/<(h[23])>(.+?)<\/h[23]>/i', function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) { @@ -31,7 +33,7 @@ $_renderedContent = preg_replace_callback( $_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id]; return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}"; }, - $Parsedown->text($rawContent) + $Parsedown->text($_rawForRender) ); ob_start(); diff --git a/templates/wizard/step1.php b/templates/wizard/step1.php index 33d3ae4..367143d 100644 --- a/templates/wizard/step1.php +++ b/templates/wizard/step1.php @@ -19,8 +19,7 @@ $_hasUuid = $_wizUuid !== '';
-

+

@@ -44,7 +43,8 @@ $_hasUuid = $_wizUuid !== '';
+ style="min-height:320px" + placeholder="# Titre de l'article Votre contenu ici…">