24 Commits

Author SHA1 Message Date
cedricAbonnel 29cb9d7723 fix: stats visiteurs — fond semi-transparent + blur sur hero
Ajout de background:rgba(0,0,0,.45) + backdrop-filter:blur(4px)
pour garantir la lisibilité sur image blanche comme sur image noire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:00:19 +02:00
cedricAbonnel 4b44486abb fix: stats visiteurs hero — blanc explicite + text-shadow pour visibilité
Remplace opacity par color:rgba(255,255,255,.95) et text-shadow
pour que les chiffres soient lisibles sur toute image de couverture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:56:59 +02:00
cedricAbonnel 9eab9ba7c3 feat: affichage clair des visiteurs 7/14/30 jours pour tous
Remplace l'affichage "X lecteurs · 30 j (Y/14 j · Z/7 j)" par
"X / 7 j · Y / 14 j · Z / 30 j lecteurs" — trois valeurs explicites
visibles par tous les visiteurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:54:17 +02:00
cedricAbonnel e12bbe1ef9 fix: ipData non défini dans la Pages IIFE cause ReferenceError
La Pages IIFE n'avait pas accès à ipData (défini seulement dans la Country IIFE).
Le ReferenceError faisait tomber le fetch dans le catch → 'Impossible de charger le flux'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:49:55 +02:00
cedricAbonnel ebef8c225e fix: conflit de variable $ips dans stats() écrase le top IPs
Le foreach artIp30 utilisait $ips comme variable de boucle, écrasant
le tableau de comptage des requêtes par IP. Résultat : ips=['66.249…':true]
au lieu des vrais top 200 IPs. Renommé en $_artIpSet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:45:45 +02:00
cedricAbonnel fce4ae6a79 fix: visitors.json clés perdues, bouton AS inaccessible, graphique visiteurs
- Fix array_merge → + pour préserver clés 7/14/30 dans visitors.json
- Bouton ✕ exclusion AS sorti du div 9rem + stopPropagation
- Handler délégué unique (removeEventListener avant de rajouter)
- Graphique trend : visiteurs uniques/jour depuis ip_data (top 200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:39:21 +02:00
cedricAbonnel dbbe60f28e feat: visiteurs uniques par article (7/14/30 j) stockés dans visitors.json
- AccessLogParser : suivi des IPs non-bot uniques par /post/ sur 3 fenêtres (7/14/30 j)
- index.php : écriture de data/UUID/visitors.json à chaque recalcul des stats admin
- post_view.php : affichage du compteur de lecteurs dans la zone hero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:27:10 +02:00
cedricAbonnel 1e41ef207e v1.6.33 : exclusion AS, compteurs 7/14/30j, 👍 uniquement
- Carte visiteurs uniques non-bot : 7 / 14 / 30 jours en tête de /admin/stats
- Bouton ✕ par AS pour l'exclure des stats ; section AS exclus avec ↺
- Alerte IPs sans résolution AS dans la carte pays
- Parser : fenêtre 30 jours, calcul visiteurs uniques toutes IPs non-bot
- Graphiques adaptés à 30 jours (labels x/3)
- Réactions articles : 👍 uniquement (suppression 🔥 et 🤔)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 22:14:40 +02:00
cedricAbonnel b0f4814bb0 v1.6.32 : UA en entier + bouton « + bot » + filtrage bots des stats
- Agents détectés : UA affiché sans troncature (drill-down et liste)
- Bouton « + bot » pour ajouter un agent aux patterns via AJAX (CSRF)
- Section Agents alimentée par all_uas (tous UAs publics, bots inclus)
- AccessLogParser : bots exclus des compteurs pages/livres/visiteurs
- Caches stats vidés après chaque modification des patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:45:10 +02:00
cedricAbonnel d53b5da31a v1.6.31 : analyse complète des logs + détection bots
- AccessLogParser : tous chemins/statuts pour IPs publiques (ipAllPaths, ipAllDays, ipAgents)
- Détection bots par patterns (data/bots.json, ~50 patterns initiaux)
- Section « Agents détectés » en bas de page admin/stats avec badge 🤖
- Panneau d'édition des patterns bots (formulaire avec CSRF)
- Drill-down IP : section « Autres chemins » (hors articles/livres)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 21:33:47 +02:00
cedricAbonnel 68a44d19d1 v1.6.30 : agents utilisateur dans le drill-down IP
- Drill-down IP : user agents affichés sous l'adresse IP, top 5 par fréquence
- AccessLogParser : regex COMBINED étendue pour capturer le UA (groupe 5)
- Tracking ipAgents [ip => [ua => count]], ip_agents dans le résultat de stats()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:16:20 +02:00
cedricAbonnel e3d7e433e0 v1.6.29 : chemins IP triés par date, un par ligne avec compteur
- Drill-down IP : articles/livres affichés un par ligne (compteur entre parenthèses), triés par date de dernier accès desc
- AccessLogParser : ipPathTs trace le dernier timestamp par chemin/IP
- ip_top_paths : structure {n, ts} au lieu de count simple

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:09:44 +02:00
cedricAbonnel 40656631ba v1.6.28 : drill-down IP par AS dans stats pays, suppression Répartition par réseau
- Admin stats : clic sur un réseau AS affiche les IPs avec mini sparkline 14 jours + articles/livres consultés
- AccessLogParser : calcul ip_data (daily + top paths) inclus dans le cache stats
- Suppression du tableau statique "Répartition par réseau" (fusionné dans accordéon pays)
- PHP-CS-Fixer appliqué sur l'ensemble des fichiers modifiés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:59:44 +02:00
cedricAbonnel d6a7033e9e feat : graphique visiteurs par pays + réseaux détail + suppression groupes AS 2026-05-19 19:44:27 +02:00
cedricAbonnel be8a95ac4f feat : graphique x3 hauteur + multi-lignes par article 2026-05-19 19:40:27 +02:00
cedricAbonnel af169bccc9 feat : graphique trafic area chart lisse avec axes (style analytics) 2026-05-19 19:37:52 +02:00
cedricAbonnel ddc7607972 merge: résolution conflits dev→main 2026-05-19 19:34:27 +02:00
cedricAbonnel a578604ec3 chore : version 1.6.27 2026-05-19 18:50:53 +02:00
cedricAbonnel e8b361e720 feat : sparklines 14j stats + filtre IPs LAN (v1.6.27)
- Admin stats : sparklines SVG par page (120×28 px, courbe + dégradé),
  carte « Pages les plus visitées » en pleine largeur
- AccessLogParser : données par jour (pages_by_day) sur 14 jours
- AccessLogParser : IPs privées/LAN exclues de la répartition réseau
- ArticleManager : suppression opérateur nullsafe superflu (PHPStan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:50:53 +02:00
cedricAbonnel 007895d24a Merge pull request 'v1.6.26 — page /books, section livres accueil, fix onglet books' (#102) from dev into main
Merge dev → main (v1.6.26)
2026-05-16 15:04:48 +00:00
cedricAbonnel 1eb6ca25f9 Merge pull request 'v1.6.25 — intégration IA éditeur, onglet admin IA, corrections CSP' (#98) from dev into main
Merge dev → main (v1.6.25)
2026-05-16 12:07:33 +00:00
cedricAbonnel d329872404 Merge pull request 'v1.6.12 — image de couverture modifiable en édition' (#80) from dev into main
Merge pull request 'v1.6.12 — image de couverture modifiable en édition' (#80) from dev into main
2026-05-15 21:09:45 +00:00
cedricAbonnel 8a42dfe981 Merge pull request 'v1.6.11 — slug immuable en édition' (#79) from dev into main
Merge pull request 'v1.6.11 — slug immuable en édition' (#79) from dev into main
2026-05-15 20:58:15 +00:00
cedricAbonnel 5203b2c514 Merge pull request 'v1.6.10 — fix suppression article (permissions répertoire)' (#78) from dev into main
Merge pull request 'v1.6.10 — fix suppression article (permissions répertoire)' (#78) from dev into main
2026-05-15 20:30:49 +00:00
24 changed files with 1317 additions and 269 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+121
View File
@@ -5,6 +5,127 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
---
## [1.6.40] - 2026-05-19
### Modifié
- Page article / stats visiteurs : fond `rgba(0,0,0,.45)` + `backdrop-filter:blur` pour lisibilité sur image claire ou foncée
---
## [1.6.39] - 2026-05-19
### Modifié
- Page article / stats visiteurs : couleur blanche explicite + text-shadow pour lisibilité sur hero image sombre ou claire
---
## [1.6.38] - 2026-05-19
### Modifié
- Page article : stats visiteurs affichées clairement pour tous — trois valeurs explicites « X / 7 j · Y / 14 j · Z / 30 j lecteurs »
---
## [1.6.37] - 2026-05-19
### Corrigé
- Admin stats / Pages : `ipData` non défini dans la Pages IIFE → ReferenceError → catch → "Impossible de charger le flux"
---
## [1.6.36] - 2026-05-19
### Corrigé
- AccessLogParser : `foreach ($this->artIp30 as $path => $ips)` écrasait la variable locale `$ips` (top IPs par volume), la remplaçant par le dernier ensemble d'IPs d'article. Renommé en `$_artIpSet`.
---
## [1.6.35] - 2026-05-19
### Corrigé
- `visitors.json` : utilisation de `+` au lieu de `array_merge` pour préserver les clés entières 7/14/30 (array_merge les renumérote en 0/1/2)
- Admin stats / Visiteurs par pays : bouton ✕ déplacé hors du div 9rem (il était écrasé par le nom de l'AS) ; `e.stopPropagation()` ajouté pour ne pas déclencher l'accordéon
- Admin stats / Visiteurs par pays : listener délégué stocké et retiré avant réajout (évite l'accumulation de handlers après chaque `renderCountry()`)
### Modifié
- Graphique "Trafic total" → "Visiteurs uniques / jour" calculé depuis les IPs du top 200 (approximation)
---
## [1.6.34] - 2026-05-19
### Ajouté
- AccessLogParser : calcul des visiteurs uniques par article (IPs non-bot publiques, /post/ statut 200) sur 7 / 14 / 30 jours — stocké dans `data/UUID/visitors.json`
- Page article : affichage du nombre de lecteurs (7 / 14 / 30 jours) dans la zone hero, recalculé à chaque visite de `/admin/stats`
---
## [1.6.33] - 2026-05-19
### Ajouté
- Admin stats : carte « Visiteurs uniques non-bot » en tête de page avec compteurs 7 / 14 / 30 jours (calculés sur toutes les IPs non-bot, pas seulement le top 200)
- Admin stats / Visiteurs par pays : bouton « ✕ » sur chaque AS pour l'exclure des stats — les AS exclus apparaissent dans une section dédiée avec bouton « ↺ Inclure »
- Admin stats / Visiteurs par pays : badge d'alerte si des IPs du top 200 n'ont pas de résolution AS
- Admin stats / Visiteurs par pays : les AS exclus sont filtrés du décompte par pays et des compteurs visiteurs (soustraction approximative via le top 200)
- Nouvelles actions AJAX `admin_add_excluded_as` / `admin_remove_excluded_as` (CSRF) pour gérer `data/excluded_as.json`
### Modifié
- AccessLogParser : fenêtre d'analyse étendue à **30 jours** (était 14) ; calcul des visiteurs uniques par période (7 / 14 / 30 jours) sur l'ensemble des IPs non-bot
- Graphiques de tendance / par article : adaptés à 30 jours (labels x toutes les 3 jours)
- Articles : un seul bouton de réaction 👍 (suppression de 🔥 et 🤔)
---
## [1.6.32] - 2026-05-19
### Modifié
- Admin stats / Agents détectés : UA affiché en entier (plus de troncature à 55 car.) dans le drill-down IP et la liste agents
- Admin stats / Agents détectés : bouton « + bot » sur chaque agent non classé — ajoute le UA aux patterns via AJAX sans recharger la page, déplace la ligne vers "Bots connus"
- Admin stats / Agents détectés : section alimentée par `FOLIO_ALL_UAS` (tous UAs publics, bots inclus) plutôt que par agrégation depuis `ip_data`
- AccessLogParser : filtrage des bots dans les compteurs pages/livres/IPs — les requêtes détectées comme bot n'alimentent plus les stats de fréquentation ; `all_uas` expose tous les UAs (bots inclus) pour la section Agents
- `index.php` : chargement de `bots.json` avant la création du parser pour passer les patterns au constructeur ; `admin_add_bot` vide les caches stats après ajout ; `admin_save_bots` vide également les caches
- Template : `FOLIO_ALL_UAS` et `FOLIO_CSRF` ajoutés aux variables JS de la page stats
---
## [1.6.31] - 2026-05-19
### Ajouté
- Admin stats : section « Agents détectés » en bas de page — agrège tous les user agents, détecte bots/humains, badge 🤖 pour les bots connus
- Admin stats : panneau d'édition des patterns bots (un par ligne, correspondance insensible à la casse), sauvegardé dans `data/bots.json`
- Admin stats / drill-down IP : section « Autres chemins » (tous chemins/statuts hors articles et livres), triée par volume
- AccessLogParser : analyse tous les chemins et statuts pour les IPs publiques (pas seulement /post/ et /book/ en 200), tracking `ipAllPaths`, `ipAllDays`, `ipAgents`
- `index.php` : action `admin_save_bots` — enregistre les patterns bots avec token CSRF ; initialisation automatique de `data/bots.json` avec ~50 patterns connus (Googlebot, GPTBot, curl, Scrapy…)
---
## [1.6.30] - 2026-05-19
### Ajouté
- Admin stats / drill-down IP : user agents affichés sous l'adresse IP (top 5 par fréquence, sans corrélation avec les pages)
- AccessLogParser : capture du user agent (groupe 5 de la regex COMBINED), tracking `ipAgents` par IP, `ip_agents` dans le résultat
---
## [1.6.29] - 2026-05-19
### Modifié
- Admin stats / drill-down IP : chemins affichés un par ligne avec compteur entre parenthèses, triés par date de dernier accès (plus récent en premier)
- AccessLogParser : suivi du dernier horodatage par chemin/IP (`ipPathTs`), `ip_top_paths` devient `{n: count, ts: timestamp}`
---
## [1.6.28] - 2026-05-19
### Ajouté
- Admin stats : drill-down AS → IPs dans l'accordéon « Visiteurs par pays » — mini sparkline 14 jours + articles/livres consultés par IP
- Admin stats : `ip_data` dans le cache stats (daily + top paths par IP publique)
### Supprimé
- Admin stats : section « Répartition par réseau » (fusionnée dans l'accordéon pays)
---
## [1.6.27] - 2026-05-19
### Ajouté
+631 -48
View File
@@ -1,19 +1,449 @@
/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP, agents */
// ── Groupes de réseaux ────────────────────────────────────────────────────────
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function trunc(s, n) {
return s.length > n ? s.slice(0, n) + '…' : s;
}
// Détection de bot par correspondance partielle insensible à la casse
var _botPatterns = (typeof FOLIO_BOT_PATTERNS !== 'undefined') ? FOLIO_BOT_PATTERNS : [];
function isBot(ua) {
if (!ua) { return false; }
var lo = ua.toLowerCase();
for (var i = 0; i < _botPatterns.length; i++) {
if (lo.indexOf(_botPatterns[i].toLowerCase()) !== -1) { return true; }
}
return false;
}
function botBadge(ua) {
return isBot(ua) ? '<span title="Bot connu" style="font-size:.85rem">🤖</span> ' : '';
}
// AS exclus (modifié dynamiquement par les boutons)
var _excludedAs = (typeof FOLIO_EXCLUDED_AS !== 'undefined') ? FOLIO_EXCLUDED_AS.slice() : [];
var _csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
// ── Résumé visiteurs ─────────────────────────────────────────────────────────
(function () {
var addBtn = document.getElementById('as-group-add');
if (!addBtn) { return; }
var el = document.getElementById('stats-summary-container');
var uv = (typeof FOLIO_UNIQUE_VISITORS !== 'undefined') ? FOLIO_UNIQUE_VISITORS : {};
var ipd = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!el) { return; }
addBtn.addEventListener('click', function () {
var tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
document.getElementById('as-groups-list').appendChild(tpl);
function computeCounts() {
var base = { 7: uv[7] || 0, 14: uv[14] || 0, 30: uv[30] || 0 };
// Soustraire les IPs des AS exclus (top 200 uniquement — approximation)
Object.keys(ipd).forEach(function (ip) {
var d = ipd[ip];
if (!d.asn || _excludedAs.indexOf(d.asn) === -1) { return; }
var daily = d.daily || [];
var n = daily.length;
if (daily.some(function (v) { return v > 0; })) { base[30] = Math.max(0, base[30] - 1); }
if (daily.slice(Math.max(0, n - 14)).some(function (v) { return v > 0; })) { base[14] = Math.max(0, base[14] - 1); }
if (daily.slice(Math.max(0, n - 7)).some(function (v) { return v > 0; })) { base[7] = Math.max(0, base[7] - 1); }
});
return base;
}
function render() {
var c = computeCounts();
el.innerHTML =
'<div class="card mb-4">'
+ '<div class="card-body py-2 px-3">'
+ '<div class="d-flex flex-wrap gap-4 align-items-center">'
+ '<span class="small fw-semibold text-muted">Visiteurs uniques non-bot</span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[7].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">7 jours</span></span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[14].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">14 jours</span></span>'
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[30].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">30 jours</span></span>'
+ (_excludedAs.length ? '<span class="badge bg-warning text-dark" style="font-size:.65rem">' + _excludedAs.length + ' AS exclu(s)</span>' : '')
+ '</div>'
+ '</div>'
+ '</div>';
}
render();
document.addEventListener('folio:excluded-as-changed', render);
}());
// ── Visiteurs par pays ────────────────────────────────────────────────────────
(function () {
var el = document.getElementById('stats-country-container');
var asList = (typeof FOLIO_AS_LIST !== 'undefined') ? FOLIO_AS_LIST : [];
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!el || !asList.length) { return; }
var _countryClickHandler = null;
var dispNames = null;
try { dispNames = new Intl.DisplayNames(['fr'], { type: 'region' }); } catch (e) {}
function countryName(code) {
if (!code || code === '??') { return 'Inconnu'; }
try { return dispNames ? dispNames.of(code) : code; } catch (e) { return code; }
}
function flag(code) {
if (!code || code.length !== 2) { return ''; }
var cp = Array.from(code.toUpperCase()).map(function (c) { return 0x1F1E6 + c.charCodeAt(0) - 65; });
return String.fromCodePoint(cp[0], cp[1]) + ' ';
}
// Index IPs par ASN
var ipsByAsn = {};
Object.keys(ipData).forEach(function (ip) {
var d = ipData[ip];
var key = d.asn || '__unknown__';
if (!ipsByAsn[key]) { ipsByAsn[key] = []; }
ipsByAsn[key].push({ ip: ip, hits: d.hits, daily: d.daily, paths: d.paths, agents: d.agents || [] });
});
Object.keys(ipsByAsn).forEach(function (k) {
ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; });
});
document.getElementById('as-groups-list').addEventListener('click', function (e) {
if (e.target.classList.contains('as-group-delete')) {
e.target.closest('.as-group-row').remove();
// IPs sans AS
var noAsCount = Object.keys(ipData).filter(function (ip) { return !ipData[ip].asn; }).length;
function ipSparkline(daily) {
if (!daily || !daily.length) { return ''; }
var W = 80, H = 20, padX = 1, padY = 2;
var max = Math.max.apply(null, daily) || 1;
var n = daily.length;
var pts = daily.map(function (v, i) {
var x = padX + i * (W - 2 * padX) / (n - 1);
var y = H - padY - (v / max) * (H - 2 * padY);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H
+ '" style="display:block;flex-shrink:0">'
+ '<polyline points="' + pts + '" fill="none" stroke="#6c757d"'
+ ' stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>'
+ '</svg>';
}
function excludeAs(asn, name) {
var fd = new FormData();
fd.append('_csrf', _csrf);
fd.append('asn', asn);
fetch('/?action=admin_add_excluded_as', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok && _excludedAs.indexOf(asn) === -1) {
_excludedAs.push(asn);
document.dispatchEvent(new CustomEvent('folio:excluded-as-changed'));
renderCountry();
}
});
}
function includeAs(asn) {
var fd = new FormData();
fd.append('_csrf', _csrf);
fd.append('asn', asn);
fetch('/?action=admin_remove_excluded_as', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok) {
_excludedAs = _excludedAs.filter(function (a) { return a !== asn; });
document.dispatchEvent(new CustomEvent('folio:excluded-as-changed'));
renderCountry();
}
});
}
function buildIpRow(ipInfo) {
// Agents sous l'IP avec badge bot (UA en entier)
var agentsHtml = '';
(ipInfo.agents || []).forEach(function (ua) {
agentsHtml += '<div style="font-size:.65rem;color:#adb5bd;line-height:1.4;word-break:break-all">'
+ botBadge(ua) + esc(ua) + '</div>';
});
// Chemins triés : /post/ et /book/ avec ts, reste sans ts
var postBook = [], other = [];
Object.keys(ipInfo.paths || {}).forEach(function (path) {
var p = ipInfo.paths[path];
var cnt = (p && typeof p === 'object') ? p.n : p;
var ts = (p && typeof p === 'object') ? p.ts : 0;
if (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); }
else { other.push({ path: path, cnt: cnt }); }
});
postBook.sort(function (a, b) { return b.ts - a.ts; });
other.sort(function (a, b) { return b.cnt - a.cnt; });
function pathLine(p, prefix) {
var raw = p.path.replace(prefix, '');
var slug = '';
try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; }
return '<div style="font-size:.75rem;line-height:1.5">'
+ '<a href="' + esc(p.path) + '" target="_blank" style="color:#495057">'
+ esc(trunc(slug || p.path, 40)) + '</a>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
function otherLine(p) {
return '<div style="font-size:.72rem;color:#868e96;line-height:1.4">'
+ '<code style="font-size:.72rem;color:#868e96">' + esc(trunc(p.path, 44)) + '</code>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
var pathsHtml = '';
var articles = postBook.filter(function (p) { return p.path.indexOf('/post/') === 0; });
var books = postBook.filter(function (p) { return p.path.indexOf('/book/') === 0; });
if (articles.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Articles</div>'
+ articles.map(function (p) { return pathLine(p, '/post/'); }).join('');
}
if (books.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Livres</div>'
+ books.map(function (p) { return pathLine(p, '/book/'); }).join('');
}
if (other.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Autres chemins</div>'
+ other.map(otherLine).join('');
}
if (!pathsHtml) { pathsHtml = '<span style="font-size:.75rem;color:#adb5bd">—</span>'; }
return '<div class="d-flex gap-2 py-2 border-bottom align-items-start">'
+ '<div style="width:9rem;flex-shrink:0">'
+ '<code style="font-size:.72rem;color:#6c757d">' + esc(ipInfo.ip) + '</code>'
+ agentsHtml
+ '</div>'
+ '<div style="flex-shrink:0;padding-top:2px">' + ipSparkline(ipInfo.daily || []) + '</div>'
+ '<div class="flex-grow-1">' + pathsHtml + '</div>'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0;padding-top:2px">'
+ (ipInfo.hits || 0).toLocaleString('fr-FR') + '</div>'
+ '</div>';
}
function renderCountry() {
// Filtrer les AS actifs / exclus
var activeLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) === -1; });
var excludedLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) !== -1; });
// Agréger par pays (AS actifs uniquement)
var byCountry = {}, asByCountry = {};
activeLists.forEach(function (as) {
var c = as.country || '??';
byCountry[c] = (byCountry[c] || 0) + as.hits;
if (!asByCountry[c]) { asByCountry[c] = []; }
asByCountry[c].push(as);
});
var countries = Object.keys(byCountry).map(function (c) {
return { code: c, hits: byCountry[c], networks: asByCountry[c] };
}).sort(function (a, b) { return b.hits - a.hits; }).slice(0, 20);
// En-tête avec alerte IPs sans AS
var headerExtra = '';
if (noAsCount > 0) {
headerExtra = '<span class="badge bg-warning text-dark ms-2" style="font-size:.65rem" '
+ 'title="' + noAsCount + ' IP(s) parmi le top 200 sans résolution AS">'
+ noAsCount + ' IP(s) sans AS</span>';
}
var countryCard = document.querySelector('#stats-country-container');
if (countryCard) {
var hdr = countryCard.closest('.card')
? countryCard.closest('.card').querySelector('.card-header')
: null;
if (hdr && !hdr.querySelector('.no-as-badge')) {
var span = document.createElement('span');
span.className = 'no-as-badge';
span.innerHTML = headerExtra;
hdr.appendChild(span);
}
}
if (!countries.length) { el.innerHTML = '<p class="text-muted mb-0">Aucune donnée.</p>'; return; }
var maxH = countries[0].hits || 1;
var html = '<div class="accordion accordion-flush" id="acc-countries">';
countries.forEach(function (c, ci) {
var pct = Math.round(c.hits / maxH * 100);
var cname = flag(c.code) + countryName(c.code);
var vis = c.hits.toLocaleString('fr-FR');
var accId = 'acc-country-' + ci;
var nets = c.networks.slice().sort(function (a, b) { return b.hits - a.hits; });
var maxN = nets[0] ? nets[0].hits : 1;
var netRows = nets.map(function (n, ni) {
var npct = Math.round(n.hits / maxN * 100);
var asId = 'acc-as-' + ci + '-' + ni;
var asnKey = n.asn || '__unknown__';
var ips = ipsByAsn[asnKey] || [];
var ipRows = ips.slice(0, 20).map(buildIpRow).join('');
var hasIps = ips.length > 0;
var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
var chevron = hasIps ? '<span class="text-muted ms-1" style="font-size:.65rem">▾</span>' : '';
var excludeBtn = n.asn
? '<button class="btn btn-sm py-0 px-1 text-muted border-0 exclude-as-btn" style="font-size:.65rem;flex-shrink:0" title="Exclure cet AS des stats" data-asn="' + esc(n.asn) + '" data-name="' + esc(n.name || '') + '">✕</button>'
: '';
return '<div>'
+ '<div class="d-flex align-items-center gap-1 py-1"' + toggleAttrs + '>'
+ '<div class="small d-flex align-items-center" style="min-width:0;flex:1 1 9rem;overflow:hidden">'
+ '<span class="text-truncate">' + esc(n.name || '?') + '</span>'
+ (n.asn ? '&nbsp;<span class="text-muted text-nowrap">AS' + esc(n.asn) + '</span>' : '')
+ chevron
+ '</div>'
+ excludeBtn
+ '<div class="flex-grow-1"><div class="progress" style="height:4px">'
+ '<div class="progress-bar bg-info" style="width:' + npct + '%"></div>'
+ '</div></div>'
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0">'
+ n.hits.toLocaleString('fr-FR') + '</div>'
+ '</div>'
+ (hasIps ? '<div id="' + asId + '" class="collapse">'
+ '<div class="border-start ms-3 ps-2 pb-1">' + ipRows + '</div>'
+ '</div>' : '')
+ '</div>';
}).join('');
html += '<div class="accordion-item border-0">'
+ '<div class="d-flex align-items-center gap-2 py-2 px-0" data-bs-toggle="collapse"'
+ ' data-bs-target="#' + accId + '" role="button" aria-expanded="false">'
+ '<div class="fw-medium" style="width:10rem;flex-shrink:0">' + cname + '</div>'
+ '<div class="flex-grow-1"><div class="progress" style="height:6px">'
+ '<div class="progress-bar" style="width:' + pct + '%"></div>'
+ '</div></div>'
+ '<div class="text-end fw-semibold" style="width:5rem;flex-shrink:0">'
+ vis + ' <span class="text-muted fw-normal small">vis.</span></div>'
+ '<span class="text-muted" style="width:1rem;flex-shrink:0;font-size:.7rem">▾</span>'
+ '</div>'
+ '<div id="' + accId + '" class="collapse">'
+ '<div class="ps-2 pb-2 border-start ms-3">' + netRows + '</div>'
+ '</div>'
+ '</div>';
});
html += '</div>';
// Section AS exclus
if (excludedLists.length) {
html += '<div class="mt-3 pt-2 border-top">'
+ '<div class="small fw-semibold text-muted mb-2">AS exclus des stats (' + excludedLists.length + ')</div>'
+ '<div class="d-flex flex-wrap gap-2">';
excludedLists.forEach(function (n) {
html += '<span class="badge border text-muted fw-normal d-inline-flex align-items-center gap-1" style="font-size:.7rem">'
+ esc(n.name || '?')
+ (n.asn ? ' <span class="text-muted">AS' + esc(n.asn) + '</span>' : '')
+ '<button class="btn btn-sm p-0 ms-1 border-0 include-as-btn" data-asn="' + esc(n.asn || '') + '" title="Inclure" style="line-height:1;color:inherit;background:none">↺</button>'
+ '</span>';
});
html += '</div></div>';
}
el.innerHTML = html;
// Délégation : boutons exclure / inclure (handler unique pour éviter les doublons)
if (_countryClickHandler) { el.removeEventListener('click', _countryClickHandler); }
_countryClickHandler = function (e) {
var btn = e.target.closest('.exclude-as-btn');
if (btn) { e.stopPropagation(); excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; }
btn = e.target.closest('.include-as-btn');
if (btn) { e.stopPropagation(); includeAs(btn.getAttribute('data-asn')); }
};
el.addEventListener('click', _countryClickHandler);
}
renderCountry();
document.addEventListener('folio:excluded-as-changed', renderCountry);
}());
// ── Liste consolidée de tous les agents ──────────────────────────────────────
(function () {
var el = document.getElementById('stats-agents-container');
var badge = document.getElementById('agents-count');
var allUas = (typeof FOLIO_ALL_UAS !== 'undefined') ? FOLIO_ALL_UAS : {};
var csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
if (!el) { return; }
var agents = Object.keys(allUas).map(function (ua) {
return { ua: ua, hits: allUas[ua], bot: isBot(ua) };
}).sort(function (a, b) {
if (a.bot !== b.bot) { return a.bot ? -1 : 1; }
return b.hits - a.hits;
});
if (!agents.length) {
el.innerHTML = '<p class="text-muted p-3 mb-0">Aucun agent détecté.</p>';
return;
}
var bots = agents.filter(function (a) { return a.bot; });
var unknown = agents.filter(function (a) { return !a.bot; });
if (badge) { badge.textContent = '— ' + bots.length + ' bot(s) sur ' + agents.length; }
function addBot(ua, btn) {
btn.disabled = true;
var fd = new FormData();
fd.append('_csrf', csrf);
fd.append('pattern', ua);
fetch('/?action=admin_add_bot', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.ok) {
_botPatterns.push(ua);
btn.closest('tr').querySelector('td:first-child').innerHTML = '<span title="Bot">🤖</span>';
btn.remove();
} else {
btn.disabled = false;
}
})
.catch(function () { btn.disabled = false; });
}
function agentRow(a) {
var addBtn = (!a.bot)
? '<button class="btn btn-outline-secondary btn-sm py-0 px-1 ms-2 add-bot-btn"'
+ ' style="font-size:.65rem;white-space:nowrap" title="Ajouter aux bots">+ bot</button>'
: '';
return '<tr>'
+ '<td class="ps-3" style="width:1.5rem;vertical-align:top;padding-top:6px">'
+ (a.bot ? '<span title="Bot">🤖</span>' : '<span class="text-muted" title="Inconnu">?</span>') + '</td>'
+ '<td style="word-break:break-all;vertical-align:top">'
+ '<code style="font-size:.72rem">' + esc(a.ua) + '</code>'
+ addBtn + '</td>'
+ '<td class="text-end text-muted small pe-3" style="width:5rem;vertical-align:top;white-space:nowrap">'
+ a.hits.toLocaleString('fr-FR') + '</td>'
+ '</tr>';
}
var botsHtml = bots.map(agentRow).join('');
var unknownHtml = unknown.map(agentRow).join('');
var html = '<div class="table-responsive">'
+ '<table class="table table-sm table-hover mb-0 small">'
+ '<thead class="table-light"><tr>'
+ '<th class="ps-3" style="width:1.5rem"></th>'
+ '<th>User-Agent</th>'
+ '<th class="text-end pe-3" style="width:5rem">Req.</th>'
+ '</tr></thead>'
+ '<tbody>';
if (botsHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Bots connus (' + bots.length + ')</small></td></tr>'
+ botsHtml;
}
if (unknownHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Agents non classés (' + unknown.length + ')</small></td></tr>'
+ unknownHtml;
}
html += '</tbody></table></div>';
el.innerHTML = html;
// Délégation : boutons "+ bot"
el.addEventListener('click', function (e) {
var btn = e.target.closest('.add-bot-btn');
if (!btn) { return; }
var row = btn.closest('tr');
var code = row ? row.querySelector('code') : null;
if (code) { addBot(code.textContent, btn); }
});
}());
@@ -22,12 +452,9 @@
var container = document.getElementById('stats-pages-container');
var badge = document.getElementById('stats-pages-count');
var pagesByDay = (typeof FOLIO_PAGES_BY_DAY !== 'undefined') ? FOLIO_PAGES_BY_DAY : {};
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
if (!container) { return; }
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function sparkline(data) {
var W = 120, H = 28, padX = 2, padY = 3;
var max = Math.max.apply(null, data) || 1;
@@ -37,7 +464,6 @@
var y = H - padY - (v / max) * (H - 2 * padY);
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
// Zone remplie sous la courbe
var first = padX.toFixed(1) + ',' + (H - padY).toFixed(1);
var last = (W - padX).toFixed(1) + ',' + (H - padY).toFixed(1);
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '" style="display:block;overflow:visible">'
@@ -54,41 +480,197 @@
var trendEl = document.getElementById('stats-trend-container');
if (!trendEl || !totals.length) { return; }
var days = totals.length;
var maxV = Math.max.apply(null, totals) || 1;
var W = 1000; // viewBox, s'adapte en CSS
var H = 80;
var padX = 4;
var padY = 8;
var barW = Math.floor((W - 2 * padX) / days) - 2;
var n = totals.length;
var VW = 900, VH = 480;
var ml = 44, mr = 12, mt = 12, mb = 28;
var W = VW - ml - mr;
var H = VH - mt - mb;
var rawMax = Math.max.apply(null, totals) || 1;
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
var maxV = Math.ceil(rawMax / mag) * mag;
var nTicks = 4;
// Dates des jours (index 0 = il y a 13 jours)
var now = new Date();
var labels = totals.map(function (_, i) {
var d = new Date(now);
d.setDate(d.getDate() - (days - 1 - i));
d.setDate(d.getDate() - (n - 1 - i));
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
});
var bars = totals.map(function (v, i) {
var x = padX + i * (W - 2 * padX) / days + 1;
var bh = Math.max(2, (v / maxV) * (H - padY - 16));
var y = H - padY - bh;
var lx = x + barW / 2;
var label = labels[i];
var showLabel = (i === 0 || i === days - 1 || i === Math.floor(days / 2));
return '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + barW + '" height="' + bh.toFixed(1) + '"'
+ ' fill="var(--bs-primary,#0d6efd)" opacity="0.75" rx="2"/>'
+ '<title>' + label + ' : ' + v + ' vis.</title>'
+ (showLabel
? '<text x="' + lx.toFixed(1) + '" y="' + (H - 1) + '" text-anchor="middle"'
+ ' font-size="10" fill="var(--bs-secondary-color,#6c757d)">' + label + '</text>'
: '');
var pts = totals.map(function (v, i) {
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
});
function smoothPath(points) {
var d = 'M ' + points[0].x.toFixed(1) + ' ' + points[0].y.toFixed(1);
for (var i = 0; i < points.length - 1; i++) {
var p0 = points[i > 0 ? i - 1 : i];
var p1 = points[i];
var p2 = points[i + 1];
var p3 = points[i + 2 < points.length ? i + 2 : i + 1];
var t = 0.35;
var cp1x = p1.x + t * (p2.x - p0.x) / 2;
var cp1y = p1.y + t * (p2.y - p0.y) / 2;
var cp2x = p2.x - t * (p3.x - p1.x) / 2;
var cp2y = p2.y - t * (p3.y - p1.y) / 2;
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
}
return d;
}
var linePath = smoothPath(pts);
var areaPath = linePath
+ ' L ' + pts[n - 1].x.toFixed(1) + ' ' + (mt + H)
+ ' L ' + pts[0].x.toFixed(1) + ' ' + (mt + H) + ' Z';
var grid = '', yLabels = '';
for (var t = 0; t <= nTicks; t++) {
var val = Math.round(maxV * t / nTicks);
var gy = (mt + H - (val / maxV) * H).toFixed(1);
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
+ '" stroke="#e9ecef" stroke-width="1"/>';
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
}
var xLabels = '';
pts.forEach(function (p, i) {
if (i % 3 === 0 || i === n - 1) {
xLabels += '<text x="' + p.x.toFixed(1) + '" y="' + (VH - 4) + '" text-anchor="middle"'
+ ' font-size="11" fill="#adb5bd">' + p.l + '</text>';
}
});
var dots = pts.map(function (p) {
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
+ ' fill="transparent" cursor="default">'
+ '<title>' + esc(p.l) + ' : ' + p.v + ' visiteur(s)</title>'
+ '</circle>'
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="3"'
+ ' fill="var(--bs-primary,#0d6efd)" stroke="#fff" stroke-width="1.5" pointer-events="none"/>';
}).join('');
trendEl.innerHTML = '<p class="small text-muted mb-2 fw-semibold">Trafic total — 14 derniers jours</p>'
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '"'
+ ' style="width:100%;height:80px;display:block">' + bars + '</svg>';
trendEl.innerHTML =
'<p class="small text-muted mb-2 fw-semibold">Visiteurs uniques / jour — 30 derniers jours <span class="fw-normal opacity-50">(top 200 IPs)</span></p>'
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
+ '<defs>'
+ '<linearGradient id="area-grad" x1="0" y1="0" x2="0" y2="1">'
+ '<stop offset="0%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0.2"/>'
+ '<stop offset="100%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0"/>'
+ '</linearGradient>'
+ '</defs>'
+ grid
+ '<path d="' + areaPath + '" fill="url(#area-grad)"/>'
+ '<path d="' + linePath + '" fill="none" stroke="var(--bs-primary,#0d6efd)"'
+ ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+ dots + yLabels + xLabels
+ '</svg>';
}
function multiLineChart(pagesByDay, rssRows) {
var el = document.getElementById('stats-multiline-container');
if (!el) { return; }
var COLORS = ['#0d6efd','#198754','#dc3545','#fd7e14','#6f42c1',
'#20c997','#0dcaf0','#e63946','#f4a261','#457b9d'];
var VW = 900, VH = 480;
var ml = 44, mr = 12, mt = 12, mb = 28;
var W = VW - ml - mr;
var H = VH - mt - mb;
var series = [];
rssRows.forEach(function (row) {
var pm = row.link.match(/\/post\/[^?#]*/);
var data = pm ? (pagesByDay[pm[0]] || null) : null;
if (data && series.length < 10) {
series.push({ title: row.title || row.slug, data: data });
}
});
if (!series.length) { return; }
var n = series[0].data.length;
var allVals = series.reduce(function (acc, s) { return acc.concat(s.data); }, []);
var rawMax = Math.max.apply(null, allVals) || 1;
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
var maxV = Math.ceil(rawMax / mag) * mag;
var nTicks = 4;
var now = new Date();
var labels = series[0].data.map(function (_, i) {
var d = new Date(now);
d.setDate(d.getDate() - (n - 1 - i));
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
});
function smoothPath(pts) {
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
for (var i = 0; i < pts.length - 1; i++) {
var p0 = pts[i > 0 ? i - 1 : i];
var p1 = pts[i], p2 = pts[i + 1];
var p3 = pts[i + 2 < pts.length ? i + 2 : i + 1];
var t = 0.35;
var cp1x = p1.x + t * (p2.x - p0.x) / 2, cp1y = p1.y + t * (p2.y - p0.y) / 2;
var cp2x = p2.x - t * (p3.x - p1.x) / 2, cp2y = p2.y - t * (p3.y - p1.y) / 2;
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
}
return d;
}
var grid = '', yLabels = '';
for (var t = 0; t <= nTicks; t++) {
var val = Math.round(maxV * t / nTicks);
var gy = (mt + H - (val / maxV) * H).toFixed(1);
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
+ '" stroke="#e9ecef" stroke-width="1"/>';
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
}
var xLabels = '';
labels.forEach(function (lbl, i) {
if (i % 3 === 0 || i === n - 1) {
var x = (ml + i * W / (n - 1)).toFixed(1);
xLabels += '<text x="' + x + '" y="' + (VH - 4) + '" text-anchor="middle"'
+ ' font-size="11" fill="#adb5bd">' + lbl + '</text>';
}
});
var lines = series.map(function (s, si) {
var color = COLORS[si % COLORS.length];
var pts = s.data.map(function (v, i) {
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
});
var dots = pts.map(function (p) {
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
+ ' fill="transparent"><title>' + esc(p.l) + ' — ' + esc(s.title) + ' : ' + p.v + ' vis.</title></circle>'
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="2.5"'
+ ' fill="' + color + '" stroke="#fff" stroke-width="1" pointer-events="none"/>';
}).join('');
return '<path d="' + smoothPath(pts) + '" fill="none" stroke="' + color
+ '" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>' + dots;
}).join('');
var legend = series.map(function (s, si) {
var color = COLORS[si % COLORS.length];
return '<span class="d-inline-flex align-items-center gap-1 me-3 mb-1 small">'
+ '<svg width="16" height="3" style="flex-shrink:0"><line x1="0" y1="1.5" x2="16" y2="1.5"'
+ ' stroke="' + color + '" stroke-width="2.5" stroke-linecap="round"/></svg>'
+ '<span class="text-truncate" style="max-width:160px" title="' + esc(s.title) + '">'
+ esc(trunc(s.title, 32)) + '</span></span>';
}).join('');
el.innerHTML =
'<p class="small text-muted mb-2 fw-semibold">Par article — 30 derniers jours</p>'
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
+ grid + lines + yLabels + xLabels + '</svg>'
+ '<div class="d-flex flex-wrap mt-2">' + legend + '</div>';
}
fetch('/trending?period=14d')
@@ -111,14 +693,15 @@
var daily = pm ? (pagesByDay[pm[0]] || null) : null;
return { title: title, link: link, slug: slug, vis: vis, daily: daily };
});
// Graphique global : somme de tous les articles par jour
var nDays = 14;
var totals = new Array(nDays).fill(0);
Object.values(pagesByDay).forEach(function (arr) {
arr.forEach(function (v, i) { if (i < nDays) { totals[i] += v; } });
var nDays = Object.values(pagesByDay)[0] ? Object.values(pagesByDay)[0].length : 30;
// Visiteurs uniques par jour — compté sur les IPs du top 200 (approximation)
var dailyVisitors = new Array(nDays).fill(0);
Object.keys(ipData).forEach(function (ip) {
var daily = ipData[ip].daily || [];
daily.forEach(function (v, i) { if (i < nDays && v > 0) { dailyVisitors[i]++; } });
});
trendChart(totals);
trendChart(dailyVisitors);
multiLineChart(pagesByDay, rows);
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small w-100"><tbody>';
rows.forEach(function (row, i) {
+185 -1
View File
@@ -896,6 +896,13 @@ switch ($action) {
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
}
$articleVisitors = [];
$_visFile = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/visitors.json';
if (($article['uuid'] ?? '') !== '' && is_file($_visFile)) {
$articleVisitors = json_decode((string) file_get_contents($_visFile), true) ?: [];
}
unset($_visFile);
include BASE_PATH . '/templates/post_view.php';
break;
@@ -2725,6 +2732,38 @@ switch ($action) {
require_once BASE_PATH . '/src/AccessLogParser.php';
require_once BASE_PATH . '/src/AsnLookup.php';
// Patterns de bots — initialisation si absent
$botsFile = DATA_PATH . '/bots.json';
if (!file_exists($botsFile)) {
$defaultBots = [
'Googlebot', 'Googlebot-Image', 'Google-InspectionTool', 'Google-Extended',
'bingbot', 'BingPreview', 'msnbot',
'DuckDuckBot', 'DuckDuckGo-Favicons-Bot',
'Baiduspider', 'YandexBot', 'YandexImages', 'YandexMetrika',
'Applebot',
'facebookexternalhit', 'facebot',
'Twitterbot', 'LinkedInBot', 'Slackbot', 'TelegramBot', 'WhatsApp', 'Discordbot',
'PetalBot', 'Bytespider', 'SogouSpider', 'SeznamBot', 'Exabot',
'AhrefsBot', 'SemrushBot', 'MJ12bot', 'DotBot', 'rogerbot', 'BLEXBot', 'DataForSeoBot',
'Screaming Frog SEO Spider',
'ClaudeBot', 'GPTBot', 'PerplexityBot', 'cohere-ai', 'anthropic-ai',
'meta-externalagent', 'OAI-SearchBot', 'Amazonbot',
'CCBot', 'ia_archiver', 'archive.org_bot',
'NetcraftSurveyAgent',
'python-requests', 'python-urllib', 'Python/',
'curl/', 'wget/', 'Wget/',
'Go-http-client/1', 'Java/', 'Apache-HttpClient', 'okhttp/',
'Scrapy', 'HeadlessChrome', 'PhantomJS', 'Puppeteer', 'Playwright', 'Selenium',
'UptimeRobot', 'Pingdom', 'StatusCake', 'Site24x7', 'GTmetrix',
'Chrome-Lighthouse', 'PageSpeed', 'Zabbix', 'check_http',
'libwww-perl', 'GuzzleHttp', 'masscan', 'zgrab', 'nuclei',
];
@mkdir(dirname($botsFile), 0755, true);
@file_put_contents($botsFile, json_encode($defaultBots, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
$botPatterns = json_decode((string) file_get_contents($botsFile), true) ?: [];
$adminData['bot_patterns'] = $botPatterns;
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
$statsRaw = null;
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
@@ -2733,16 +2772,34 @@ switch ($action) {
if ($statsRaw === null) {
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 30, $botPatterns);
$accessStats = $accessParser->stats();
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$ipData = [];
foreach ($accessStats['ips_by_day'] ?? [] as $ip => $daily) {
$info = $asnMap[$ip] ?? ['asn' => '', 'name' => '?', 'country' => ''];
$ipData[$ip] = [
'hits' => $topIps[$ip] ?? (int) array_sum($daily),
'asn' => $info['asn'],
'name' => $info['name'],
'country' => $info['country'],
'daily' => $daily,
'paths' => $accessStats['ip_top_paths'][$ip] ?? [],
'agents' => $accessStats['ip_agents'][$ip] ?? [],
];
}
$statsRaw = [
'readable' => $accessParser->isReadable(),
'books' => $tParser->top($cutoff14, 20, ['/book/']),
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
'pages_by_day' => $accessStats['pages_by_day'] ?? [],
'ip_data' => $ipData,
'all_uas' => $accessStats['all_uas'] ?? [],
'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0],
'article_unique_visitors' => $accessStats['article_unique_visitors'] ?? [],
];
@file_put_contents($statsCacheFile, json_encode($statsRaw));
}
@@ -2752,6 +2809,31 @@ switch ($action) {
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
$adminData['as_groups'] = asGroups();
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
$adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
// Écriture des visitors.json par article (slug → UUID via l'index)
$_slugIndex = is_file(DATA_PATH . '/_cache/slug_index.json')
? (json_decode((string) file_get_contents(DATA_PATH . '/_cache/slug_index.json'), true) ?: [])
: [];
foreach (($statsRaw['article_unique_visitors'] ?? []) as $_artPath => $_artCounts) {
$_artSlug = rawurldecode(substr((string) $_artPath, 6));
$_artUuid = $_slugIndex[$_artSlug] ?? null;
if ($_artUuid !== null && preg_match('/^[0-9a-f\-]{36}$/i', $_artUuid)) {
@file_put_contents(
DATA_PATH . '/' . $_artUuid . '/visitors.json',
json_encode($_artCounts + ['updated' => time()], JSON_UNESCAPED_UNICODE)
);
}
}
unset($_slugIndex, $_artPath, $_artCounts, $_artSlug, $_artUuid);
// AS exclus (chargé en direct, pas mis en cache)
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$adminData['excluded_as'] = is_file($excludedAsFile)
? (json_decode((string) file_get_contents($excludedAsFile), true) ?: [])
: [];
}
if ($tab === 'categories') {
@@ -3190,6 +3272,108 @@ switch ($action) {
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_save_bots':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$botsFile = DATA_PATH . '/bots.json';
$patterns = array_values(array_unique(array_filter(
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
)));
$ok = @file_put_contents($botsFile, json_encode($patterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
if ($ok) {
@unlink(DATA_PATH . '/.stats_cache.json');
@unlink(BASE_PATH . '/_cache/access_stats.json');
}
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
exit;
case 'admin_add_bot':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$csrf = $_POST['_csrf'] ?? '';
if ($csrf !== ($_session['csrf'] ?? '')) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'csrf']);
exit;
}
$addPattern = trim((string) ($_POST['pattern'] ?? ''));
if ($addPattern === '') {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'empty']);
exit;
}
$botsFile = DATA_PATH . '/bots.json';
$botPatterns = is_file($botsFile) ? (json_decode((string) file_get_contents($botsFile), true) ?: []) : [];
if (!in_array($addPattern, $botPatterns, true)) {
$botPatterns[] = $addPattern;
@file_put_contents($botsFile, json_encode($botPatterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
@unlink(DATA_PATH . '/.stats_cache.json');
@unlink(BASE_PATH . '/_cache/access_stats.json');
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'pattern' => $addPattern]);
exit;
case 'admin_add_excluded_as':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$csrf = $_POST['_csrf'] ?? '';
if ($csrf !== ($_session['csrf'] ?? '')) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'csrf']);
exit;
}
$asn = trim((string) ($_POST['asn'] ?? ''));
if ($asn === '') {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'empty']);
exit;
}
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
if (!in_array($asn, $excludedAs, true)) {
$excludedAs[] = $asn;
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'asn' => $asn]);
exit;
case 'admin_remove_excluded_as':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$csrf = $_POST['_csrf'] ?? '';
if ($csrf !== ($_session['csrf'] ?? '')) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'csrf']);
exit;
}
$asn = trim((string) ($_POST['asn'] ?? ''));
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
$excludedAs = array_values(array_filter($excludedAs, static fn ($a) => $a !== $asn));
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'asn' => $asn]);
exit;
case 'admin_create_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+1
View File
@@ -1,4 +1,5 @@
<?php
declare(strict_types=1);
if (!defined('BASE_PATH')) {
+1 -1
View File
@@ -145,7 +145,7 @@ ob_start();
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
'1y' => '8 h',
];
foreach (TENDANCES_PERIODS as $p => $info):
foreach (TENDANCES_PERIODS as $p => $info):
$url = $base . '/trending?period=' . rawurlencode($p);
?>
<tr>
+1 -1
View File
@@ -122,7 +122,7 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
$plural = $v > 1 ? 's' : '';
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
?>
?>
<item>
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
<link><?= $link ?></link>
+1 -1
View File
@@ -1 +1 @@
1.6.27
1.6.40
+1 -1
View File
@@ -57,7 +57,7 @@ foreach ($pending as $file) {
echo "\n";
$count++;
} catch (Throwable $e) {
echo "" . $e->getMessage() . "\n";
echo '✗ ' . $e->getMessage() . "\n";
$errors++;
break;
}
+208 -26
View File
@@ -9,28 +9,53 @@ class AccessLogParser
private string $cacheFile;
private int $cacheTtl;
private int $days;
/** @var list<string> */
private array $botPatterns;
/** @var array<string,array<string,true>> */
private array $artIp7 = [];
/** @var array<string,array<string,true>> */
private array $artIp14 = [];
/** @var array<string,array<string,true>> */
private array $artIp30 = [];
private static ?array $memo = null;
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) /';
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/u';
/**
* @param list<string> $botPatterns
*/
public function __construct(
string $logDir = '/var/log/apache2',
string $pattern = '*-access.log',
string $cacheFile = '',
int $cacheTtl = 600,
int $days = 14
int $days = 30,
array $botPatterns = []
) {
$this->logDir = rtrim($logDir, '/');
$this->pattern = $pattern;
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
$this->cacheTtl = $cacheTtl;
$this->days = $days;
$this->botPatterns = array_map('strtolower', $botPatterns);
}
/**
* @return array{pages:array<string,int>,books:array<string,int>,ips:array<string,int>,pages_by_day:array<string,list<int>>}
* @return array{
* pages:array<string,int>,
* books:array<string,int>,
* ips:array<string,int>,
* pages_by_day:array<string,list<int>>,
* ips_by_day:array<string,list<int>>,
* ip_top_paths:array<string,array<string,array{n:int,ts:int}>>,
* ip_agents:array<string,list<string>>,
* all_uas:array<string,int>,
* unique_visitors:array<int,int>,
* article_unique_visitors:array<string,array<int,int>>
* }
*/
public function stats(): array
{
@@ -47,20 +72,25 @@ class AccessLogParser
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
$pages = [];
$books = [];
$ips = [];
$dayPages = []; // [path => [dayOffset => count]], dayOffset 0=oldest
$ips = []; // requêtes publiques non-bot (tous chemins, tous statuts)
$dayPages = [];
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
$ipPathTs = [];
$ipAllPaths = []; // tous chemins, tous statuts, non-bots
$ipAllDays = []; // tous jours, tous statuts, non-bots
$ipAgents = []; // user-agents non-bot par IP
$allUas = []; // tous UAs publics (bots inclus) pour "Agents détectés"
foreach ($this->logFiles() as $file) {
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages);
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
arsort($pages);
arsort($books);
arsort($ips);
arsort($allUas);
// Normalise dayPages : pour chaque page, tableau de $this->days entiers (index 0 = le plus ancien)
$pagesByDay = [];
$today = (int) strtotime('today midnight');
foreach ($dayPages as $path => $byOffset) {
$arr = array_fill(0, $this->days, 0);
foreach ($byOffset as $offset => $count) {
@@ -71,9 +101,89 @@ class AccessLogParser
$pagesByDay[$path] = $arr;
}
$result = ['pages' => $pages, 'books' => $books, 'ips' => $ips, 'pages_by_day' => $pagesByDay];
// Top 200 IPs non-bot par volume total de requêtes
$topIpKeys = array_keys(array_slice($ips, 0, 200, true));
$ipsByDay = [];
$ipTopPaths = [];
$ipTopAgents = [];
foreach ($topIpKeys as $ip) {
// Sparkline : activité totale par jour
$arr = array_fill(0, $this->days, 0);
foreach ($ipAllDays[$ip] ?? [] as $offset => $count) {
if ($offset >= 0 && $offset < $this->days) {
$arr[$offset] = $count;
}
}
$ipsByDay[$ip] = $arr;
// Top 20 chemins tous types confondus
$allPaths = $ipAllPaths[$ip] ?? [];
arsort($allPaths);
$ipTopPaths[$ip] = [];
foreach (array_slice($allPaths, 0, 20, true) as $p => $cnt) {
$ipTopPaths[$ip][$p] = ['n' => $cnt, 'ts' => $ipPathTs[$ip][$p] ?? 0];
}
// Top 5 user-agents
$agents = $ipAgents[$ip] ?? [];
arsort($agents);
$ipTopAgents[$ip] = array_keys(array_slice($agents, 0, 5, true));
}
// Visiteurs uniques par période — calculé sur TOUS les IPs non-bot (pas seulement le top 200)
$uniqueVisitors = [7 => 0, 14 => 0, 30 => 0];
$start7 = $this->days - 7;
$start14 = $this->days - 14;
foreach ($ipAllDays as $ipDay) {
$active7 = $active14 = $active30 = false;
foreach ($ipDay as $offset => $cnt) {
if ($cnt <= 0) {
continue;
}
$active30 = true;
if ($offset >= $start14) {
$active14 = true;
}
if ($offset >= $start7) {
$active7 = true;
}
}
if ($active7) {
++$uniqueVisitors[7];
}
if ($active14) {
++$uniqueVisitors[14];
}
if ($active30) {
++$uniqueVisitors[30];
}
}
// Visiteurs uniques par article (IPs publiques non-bot, /post/ statut 200)
$articleUv = [];
foreach ($this->artIp30 as $path => $_artIpSet) {
$articleUv[$path] = [
'7' => count($this->artIp7[$path] ?? []),
'14' => count($this->artIp14[$path] ?? []),
'30' => count($_artIpSet),
];
}
$result = [
'pages' => $pages,
'books' => $books,
'ips' => $ips,
'pages_by_day' => $pagesByDay,
'ips_by_day' => $ipsByDay,
'ip_top_paths' => $ipTopPaths,
'ip_agents' => $ipTopAgents,
'all_uas' => array_slice($allUas, 0, 300, true),
'unique_visitors' => $uniqueVisitors,
'article_unique_visitors' => $articleUv,
];
@mkdir(dirname($this->cacheFile), 0755, true);
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::$memo = $result;
}
@@ -88,6 +198,21 @@ class AccessLogParser
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
}
private function matchesBot(string $ua): bool
{
if ($ua === '' || $this->botPatterns === []) {
return false;
}
$lo = strtolower($ua);
foreach ($this->botPatterns as $p) {
if ($p !== '' && str_contains($lo, $p)) {
return true;
}
}
return false;
}
/** @return list<array{path:string,type:string}> */
private function logFiles(): array
{
@@ -124,43 +249,100 @@ class AccessLogParser
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
return 0;
}
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
}
private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void
{
private function parseLine(
string $line,
int $cutoff,
array &$pages,
array &$books,
array &$ips,
array &$dayPages,
array &$ipPaths,
array &$ipPathTs,
array &$ipAllPaths,
array &$ipAllDays,
array &$ipAgents,
array &$allUas
): void {
if (!preg_match(self::RE, $line, $m)) {
return;
}
[, $ip, $ts, $path, $status] = $m;
[, $ip, $ts, $path, $status, $ua] = $m;
if ($status !== '200') {
return;
}
$tsVal = self::parseTimestamp($ts);
if ($tsVal < $cutoff) {
return;
}
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
$isBot = $this->matchesBot($ua);
// Tous les UAs publics pour la section "Agents détectés" (bots inclus)
if ($publicIp && $ua !== '') {
$allUas[$ua] = ($allUas[$ua] ?? 0) + 1;
}
// Requêtes publiques non-bot : comptage visiteurs, chemins, jours, agents
if ($publicIp && !$isBot) {
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
$ipAllPaths[$ip][$path] = ($ipAllPaths[$ip][$path] ?? 0) + 1;
$ipAllDays[$ip][$dayOffset] = ($ipAllDays[$ip][$dayOffset] ?? 0) + 1;
if ($ua !== '') {
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
}
}
// Comptage spécifique aux pages de contenu (statut 200, non-bot)
if ($status !== '200' || $isBot) {
return;
}
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
$pages[$path] = ($pages[$path] ?? 0) + 1;
if ($publicIp) {
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
}
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
} elseif (str_starts_with($path, '/book/') && strlen($path) > 6) {
if ($publicIp) {
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
$ipPathTs[$ip][$path] = $tsVal;
}
// Visiteurs uniques par article (IPs publiques non-bot uniquement)
$this->artIp30[$path][$ip] = true;
if ($dayOffset >= $this->days - 14) {
$this->artIp14[$path][$ip] = true;
}
if ($dayOffset >= $this->days - 7) {
$this->artIp7[$path][$ip] = true;
}
}
} elseif (str_ends_with($path, '/') === false && str_starts_with($path, '/book/') && strlen($path) > 6) {
$books[$path] = ($books[$path] ?? 0) + 1;
if ($publicIp) {
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
$ipPathTs[$ip][$path] = $tsVal;
}
}
}
}
private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void
{
private function parseFile(
array $file,
int $cutoff,
array &$pages,
array &$books,
array &$ips,
array &$dayPages,
array &$ipPaths,
array &$ipPathTs,
array &$ipAllPaths,
array &$ipAllDays,
array &$ipAgents,
array &$allUas
): void {
if ($file['type'] === 'tgz') {
try {
$phar = new PharData($file['path']);
@@ -170,7 +352,7 @@ class AccessLogParser
continue;
}
foreach (explode("\n", $content) as $line) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
}
} catch (\Exception $e) {
@@ -183,7 +365,7 @@ class AccessLogParser
while (!gzeof($h)) {
$line = gzgets($h, 8192);
if ($line !== false) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
}
gzclose($h);
@@ -193,7 +375,7 @@ class AccessLogParser
return;
}
while (($line = fgets($h)) !== false) {
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
}
fclose($h);
}
+1 -1
View File
@@ -95,7 +95,7 @@ class BookManager
$this->bookPath($slug),
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
$this->git?->commit("book: " . ($book['title'] ?? $slug));
$this->git?->commit('book: ' . ($book['title'] ?? $slug));
}
public function delete(string $slug): void
+3 -1
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
class DataGit
{
public function __construct(private string $dataDir) {}
public function __construct(private string $dataDir)
{
}
public function commit(string $message): void
{
+6 -2
View File
@@ -61,7 +61,9 @@ PROMPT;
$raw = $this->provider === 'claude_code'
? $this->queryClaudeCode(self::SYSTEM_ANALYZE, $userMsg)
: $this->queryAnthropicRaw(self::SYSTEM_ANALYZE, $userMsg, 4096);
if (!$raw['ok']) return $raw;
if (!$raw['ok']) {
return $raw;
}
return $this->parseAnalyzeResponse($raw['text'] ?? '');
}
@@ -129,7 +131,9 @@ PROMPT;
$err = curl_error($ch);
curl_close($ch);
if ($err !== '') return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
if ($err !== '') {
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
}
$data = json_decode((string) $resp, true);
if ($http !== 200) {
+6 -2
View File
@@ -96,14 +96,18 @@ function asGroups(): array
function aiProvider(): string
{
$v = siteSettings()['ai_provider'] ?? '';
if ($v !== '') return $v;
if ($v !== '') {
return $v;
}
return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic';
}
function aiModel(): string
{
$v = siteSettings()['ai_model'] ?? '';
if ($v !== '') return $v;
if ($v !== '') {
return $v;
}
return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001';
}
+2 -1
View File
@@ -15,7 +15,8 @@ class TrendingParser
public function __construct(
private string $logDir,
private string $pattern,
) {}
) {
}
/**
* Retourne les $limit chemins les plus consultés depuis $cutoff,
+6 -2
View File
@@ -66,8 +66,12 @@ function lineDiff(string $old, string $new): array
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]; }
foreach ($a as $line) {
$diff[] = ['-', $line];
}
foreach ($b as $line) {
$diff[] = ['+', $line];
}
return $diff;
}
+10 -8
View File
@@ -230,7 +230,9 @@ function adminStatusBadge(array $a, int $now): string
return '/admin/articles?' . http_build_query($p);
};
$_sortIcon = function (string $col) use ($_sortBy, $_sortDir): string {
if ($_sortBy !== $col) { return '<span class="text-muted ms-1" style="font-size:.75em">↕</span>'; }
if ($_sortBy !== $col) {
return '<span class="text-muted ms-1" style="font-size:.75em">↕</span>';
}
return '<span class="ms-1" style="font-size:.75em">' . ($_sortDir === 'asc' ? '↑' : '↓') . '</span>';
};
?>
@@ -389,7 +391,7 @@ function adminStatusBadge(array $a, int $now): string
'sort' => $_sortBy,
'dir' => $_sortDir,
], fn ($v) => $v !== ''));
foreach ($adminData['articles'] as $_fa):
foreach ($adminData['articles'] as $_fa):
?>
<form id="toggle-featured-<?= htmlspecialchars($_fa['uuid']) ?>" method="post" action="/?action=admin_toggle_featured" hidden>
<input type="hidden" name="uuid" value="<?= htmlspecialchars($_fa['uuid']) ?>">
@@ -1495,11 +1497,11 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php
$_aiNotice = $adminData['ai_notice'] ?? '';
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
$_aiModel = $adminData['ai_model'] ?? '';
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
$_cliOk = $adminData['claude_cli_found'] ?? false;
?>
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
$_aiModel = $adminData['ai_model'] ?? '';
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
$_cliOk = $adminData['claude_cli_found'] ?? false;
?>
<?php if ($_aiNotice === 'saved'): ?>
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
@@ -1619,6 +1621,6 @@ sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Répond
<?php endif; ?>
<?php
$content = ob_get_clean();
$content = ob_get_clean();
$title = 'Administration — ' . siteTitle();
include __DIR__ . '/layout.php';
+46 -107
View File
@@ -4,10 +4,12 @@ $_statsError = ($_GET['error'] ?? '') === 'write';
$_readable = $adminData['stats_readable'] ?? false;
$_books = $adminData['stats_books'] ?? [];
$_asList = $adminData['stats_as'] ?? [];
$_asGroups = $adminData['stats_as_groups'] ?? [];
$_groups = $adminData['as_groups'] ?? [];
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
$_activeGroup = trim($_GET['group'] ?? '');
$_ipData = $adminData['stats_ip_data'] ?? [];
$_botPatterns = $adminData['bot_patterns'] ?? [];
$_allUas = $adminData['stats_all_uas'] ?? [];
$_uniqueVisitors = $adminData['stats_unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
$_excludedAs = $adminData['excluded_as'] ?? [];
?>
<?php if ($_statsSaved): ?>
@@ -23,9 +25,20 @@ $_activeGroup = trim($_GET['group'] ?? '');
</div>
<?php else: ?>
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · flux RSS XML</p>
<p class="text-muted small mb-4">30 derniers jours · tous les chemins · flux RSS XML</p>
<script>var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;</script>
<script>
var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_AS_LIST = <?= json_encode($_asList, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_BOT_PATTERNS = <?= json_encode($_botPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_ALL_UAS = <?= json_encode($_allUas, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_CSRF = <?= json_encode($_session['csrf'] ?? '', JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_UNIQUE_VISITORS = <?= json_encode($_uniqueVisitors, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
var FOLIO_EXCLUDED_AS = <?= json_encode($_excludedAs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>
<div id="stats-summary-container"></div>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
@@ -36,10 +49,17 @@ $_activeGroup = trim($_GET['group'] ?? '');
<p class="text-muted p-3 mb-0">Chargement…</p>
</div>
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-trend-container"></div>
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-multiline-container"></div>
</div>
<div class="card mb-4">
<div class="card-header bg-transparent py-2 small fw-semibold">Visiteurs par pays</div>
<div class="card-body p-3" id="stats-country-container">
<p class="text-muted mb-0">Chargement…</p>
</div>
</div>
<div class="row g-4">
<!-- Livres -->
<div class="col-lg-6">
<div class="card h-100">
@@ -84,116 +104,35 @@ $_activeGroup = trim($_GET['group'] ?? '');
</div>
</div>
</div>
</div><!-- /row -->
<!-- Répartition par réseau -->
<!-- Agents détectés -->
<div class="card mt-4">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex align-items-center gap-3 flex-wrap">
<span>Répartition par réseau</span>
<?php if (!empty($_groups)): ?>
<div class="d-flex gap-1 flex-wrap">
<a href="/admin/stats" class="badge <?= $_activeGroup === '' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Tous</a>
<?php foreach ($_groups as $g): ?>
<a href="/admin/stats?group=<?= rawurlencode($g['label']) ?>"
class="badge <?= $_activeGroup === $g['label'] ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">
<?= htmlspecialchars($g['label']) ?>
</a>
<?php endforeach; ?>
<a href="/admin/stats?group=Autres"
class="badge <?= $_activeGroup === 'Autres' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Autres</a>
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between align-items-center">
<span>Agents détectés <span class="text-muted fw-normal" id="agents-count"></span></span>
<button class="btn btn-sm btn-outline-secondary py-0" type="button"
data-bs-toggle="collapse" data-bs-target="#agents-edit-panel">
Gérer les patterns
</button>
</div>
<?php endif; ?>
<div class="card-body p-0" id="stats-agents-container">
<p class="text-muted p-3 mb-0">Chargement…</p>
</div>
<div class="card-body p-0">
<?php
// Sélectionner les AS à afficher
if ($_activeGroup !== '' && isset($_asGroups[$_activeGroup])) {
$displayAs = $_asGroups[$_activeGroup];
} else {
$displayAs = $_asList;
}
?>
<?php if (empty($displayAs)): ?>
<p class="text-muted p-3 mb-0">
<?= empty($_asList) ? 'Aucune IP résolue (LAN ou logs vides).' : 'Aucun AS dans ce groupe.' ?>
</p>
<?php else: ?>
<?php $maxAS = max(array_column($displayAs, 'hits')) ?: 1; ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2rem">#</th>
<th>Réseau</th>
<th style="width:3rem">Pays</th>
<th style="width:5rem" class="text-end pe-3">Visites</th>
</tr>
</thead>
<tbody>
<?php foreach ($displayAs as $i => $as): ?>
<tr>
<td class="text-muted ps-3"><?= $i + 1 ?></td>
<td>
<span class="fw-medium"><?= htmlspecialchars($as['name'] ?: '?') ?></span>
<?php if ($as['asn'] !== ''): ?>
<span class="text-muted ms-1">AS<?= htmlspecialchars($as['asn']) ?></span>
<?php endif; ?>
<div class="progress mt-1" style="height:3px">
<div class="progress-bar bg-info" style="width:<?= round($as['hits'] / $maxAS * 100) ?>%"></div>
<!-- Panneau d'édition des patterns bots -->
<div id="agents-edit-panel" class="collapse">
<div class="card-footer bg-transparent border-top p-3">
<p class="small text-muted mb-2">Un pattern par ligne (correspondance insensible à la casse, recherche partielle dans le User-Agent).</p>
<form method="post" action="/?action=admin_save_bots">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($_session['csrf'] ?? '') ?>">
<textarea name="bot_patterns" class="form-control form-control-sm font-monospace mb-2"
rows="12" style="font-size:.75rem"><?= htmlspecialchars(implode("\n", $_botPatterns)) ?></textarea>
<button type="submit" class="btn btn-sm btn-primary">Enregistrer les patterns</button>
</form>
</div>
</td>
<td class="text-muted"><?= htmlspecialchars($as['country']) ?></td>
<td class="text-end fw-semibold pe-3"><?= number_format($as['hits'], 0, ',', '\u{202F}') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; // readable?>
<!-- Groupes de réseaux -->
<div class="card mt-4" style="max-width:600px">
<div class="card-header bg-transparent py-2 small fw-semibold">Groupes de réseaux</div>
<div class="card-body">
<p class="text-muted small">Regroupez plusieurs réseaux sous un label. Chaque ligne est un motif cherché dans le nom du réseau (insensible à la casse).</p>
<form method="post" action="/?action=admin_save_as_groups" id="as-groups-form">
<div id="as-groups-list">
<?php foreach ($_groups as $gi => $g): ?>
<div class="as-group-row border rounded p-3 mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
placeholder="Label (ex : Opérateurs FR)"
value="<?= htmlspecialchars($g['label']) ?>" required>
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
</div>
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
rows="3" placeholder="Un motif par ligne&#10;ex : Free SAS&#10;Orange&#10;SFR"><?= htmlspecialchars(implode("\n", $g['patterns'])) ?></textarea>
</div>
<?php endforeach; ?>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" id="as-group-add" class="btn btn-outline-secondary btn-sm">+ Ajouter un groupe</button>
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
</div>
</form>
</div>
</div>
<template id="as-group-tpl">
<div class="as-group-row border rounded p-3 mb-3">
<div class="d-flex align-items-center gap-2 mb-2">
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
placeholder="Label (ex : Moteurs de recherche)" required>
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
</div>
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
rows="3" placeholder="Un motif par ligne&#10;ex : Googlebot&#10;Bingbot"></textarea>
</div>
</template>
<script src="/assets/js/admin-stats.js" defer></script>
+3 -3
View File
@@ -21,9 +21,9 @@ ob_start();
$_cover = $_first['cover'] ?? '';
$_cat = trim($_first['category'] ?? '');
$_coverStyle = $_cover !== ''
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . "&name=" . rawurlencode($_cover) . "')"
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
?>
?>
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
<div class="book-home-card-cover" style="<?= $_coverStyle ?>"></div>
<div class="book-home-card-body">
@@ -42,7 +42,7 @@ ob_start();
</div>
<?php
$content = ob_get_clean();
$content = ob_get_clean();
$title = 'Livres — ' . siteTitle();
$metaRobots = 'index, follow';
$canonical = rtrim((string)($_ENV['APP_URL'] ?? getenv('APP_URL') ?: ''), '/') . '/books';
-2
View File
@@ -10,8 +10,6 @@
$_reactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
];
$_csrfToken = bin2hex(random_bytes(16));
+5 -4
View File
@@ -48,13 +48,14 @@
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<?php
$_pub = BASE_PATH . '/public/assets/';
if (!function_exists('_av')) {
function _av(string $base, string $rel): string {
if (!function_exists('_av')) {
function _av(string $base, string $rel): string
{
$f = $base . $rel;
return '/assets/' . $rel . (is_file($f) ? '?v=' . substr(md5_file($f), 0, 8) : '');
}
}
?>
}
?>
<link rel="stylesheet" href="<?= _av($_pub, 'css/style.css') ?>">
</head>
+1 -1
View File
@@ -228,7 +228,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
$_cover = $_first['cover'] ?? '';
$_cat = trim($_first['category'] ?? '');
$_coverStyle = $_cover !== ''
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . "&name=" . rawurlencode($_cover) . "')"
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
?>
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
+28 -8
View File
@@ -178,17 +178,37 @@ $hasSources = (!empty($externalLinks) || !empty($files))
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
<?php endif; ?>
</p>
<?php
$_v30 = (int) ($articleVisitors[30] ?? $articleVisitors['30'] ?? 0);
$_v14 = (int) ($articleVisitors[14] ?? $articleVisitors['14'] ?? 0);
$_v7 = (int) ($articleVisitors[7] ?? $articleVisitors['7'] ?? 0);
if ($_v30 > 0):
?>
<p class="article-hero-visitors" style="margin-top:.5rem;font-size:.82rem;color:#fff;display:inline-block;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);padding:.15rem .55rem;border-radius:.35rem">
<span title="Lecteurs uniques sur 7 jours">
<strong><?= number_format($_v7, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 7 j</span>
</span>
<span style="opacity:.45">&nbsp;·&nbsp;</span>
<span title="Lecteurs uniques sur 14 jours">
<strong><?= number_format($_v14, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 14 j</span>
</span>
<span style="opacity:.45">&nbsp;·&nbsp;</span>
<span title="Lecteurs uniques sur 30 jours">
<strong><?= number_format($_v30, 0, ',', "\xE2\x80\xAF") ?></strong>&thinsp;<span style="opacity:.7;font-size:.75em">/ 30 j</span>
</span>
<span style="opacity:.65">&nbsp;lecteurs</span>
</p>
<?php endif;
unset($_v30, $_v14, $_v7); ?>
</div>
<div class="article-hero-right">
<?php if ($hasSources): ?>
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn"> Sources</a>
<?php endif; ?>
<?php
$_heroReactionDefs = [
$_heroReactionDefs = [
'useful' => ['👍', 'Utile'],
'important' => ['🔥', 'Important'],
'interesting' => ['🤔', 'À creuser'],
];
];
?>
<div class="hero-reactions" id="reactions">
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
@@ -294,9 +314,9 @@ $hasSources = (!empty($externalLinks) || !empty($files))
<?php if ($article['published'] ?? false): ?>
<?php
$_shareUrl = rtrim(defined('APP_URL') ? APP_URL : '', '/') . '/post/' . rawurlencode($article['slug'] ?? '');
$_shareTitle = $article['title'] ?? '';
?>
$_shareUrl = rtrim(defined('APP_URL') ? APP_URL : '', '/') . '/post/' . rawurlencode($article['slug'] ?? '');
$_shareTitle = $article['title'] ?? '';
?>
<div class="d-flex flex-wrap align-items-center gap-2 my-3 py-2 border-top"
id="share-bar"
data-url="<?= htmlspecialchars($_shareUrl) ?>"
@@ -442,7 +462,7 @@ $_shareTitle = $article['title'] ?? '';
<?php
$_revisions = array_reverse($article['revisions'] ?? []);
if (!empty($_revisions) && isLoggedIn()):
if (!empty($_revisions) && isLoggedIn()):
?>
<h6 class="related-sidebar-title mt-3">Historique</h6>
<ul class="toc-list small">
+3 -1
View File
@@ -201,5 +201,7 @@ $_hasUuid = $_wizUuid !== '';
<?php
$content = ob_get_clean();
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
if ($mode === 'edit') { $aiEditor = true; }
if ($mode === 'edit') {
$aiEditor = true;
}
include BASE_PATH . '/templates/layout.php';