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] =?UTF-8?q?feat=20:=20versionnage=20semver,=20migrations?= =?UTF-8?q?=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 !== '';
- -
- - -
-