feat : wizard multi-étapes, migrations contenu, versionnage semver (v1.2.1) #60
+74
-125
@@ -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
|
||||||
|
|
||||||
### Fonctionnalités
|
### 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
|
||||||
|
|
||||||
- **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
@@ -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
@@ -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¬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':
|
case 'admin_save_site':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
Executable
+36
@@ -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"
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 Votre contenu ici…"><?= htmlspecialchars($content ?? '') ?></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /col-lg-9 -->
|
</div><!-- /col-lg-9 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user