Merge pull request 'feat : statistiques admin, livres, setup.sh, permissions rsync (v1.3.0)' (#66) from feat/books into main
feat/books → main
This commit was merged in pull request #66.
This commit is contained in:
@@ -40,6 +40,11 @@ SMTP_FROM_NAME=
|
|||||||
CONTACT_EMAIL=
|
CONTACT_EMAIL=
|
||||||
CONTACT_FROM_EMAIL=
|
CONTACT_FROM_EMAIL=
|
||||||
|
|
||||||
|
# Chemin absolu vers le répertoire des articles (data/)
|
||||||
|
# Par défaut : BASE_PATH/data (dans le répertoire de l'application)
|
||||||
|
# Recommandé en production : chemin hors du répertoire web, ex. /srv/data/folio
|
||||||
|
DATA_PATH=/srv/data/folio
|
||||||
|
|
||||||
# Logs Apache (onglet Recherches dans /admin)
|
# Logs Apache (onglet Recherches dans /admin)
|
||||||
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
# Nom du fichier de log d'accès du vhost dans /var/log/apache2/
|
||||||
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
|
APACHE_ACCESS_LOG=lan.acegrp.varlog-access.log
|
||||||
|
|||||||
@@ -9,6 +9,34 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-05-15
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **`DATA_PATH`** : chemin des articles configurable via `.env`, indépendant du document root — permet de stocker `/data` hors de l'arborescence web (ex. `/srv/data/folio`)
|
||||||
|
- **`DataGit`** : auto-commit git sur toutes les écritures articles et livres (création, modification, suppression, métadonnées, tags, fichiers, liens…) sauf `autosave` — no-op silencieux si `DATA_PATH` n'est pas un dépôt git
|
||||||
|
- **Admin — Moteur Folio** : affiche la branche suivie pour les mises à jour (`FOLIO_UPDATE_BRANCH`, défaut `main`), la date du dernier contrôle, et un bouton **Vérifier** pour forcer la vérification sans attendre le TTL du cache (1 h)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- `UpdateChecker` : branche cible configurable via `FOLIO_UPDATE_BRANCH` (plus de `main` hardcodé dans l'URL Gitea)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-05-15
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Onglet **Statistiques** dans l'admin : pages les plus visitées, livres consultés, répartition par AS (#64)
|
||||||
|
- `AccessLogParser` : lecture des logs Apache (plain, `.gz`, `.tar.gz`), cache 10 min
|
||||||
|
- `AsnLookup` : résolution ASN via ip-api.com (batch, cache 30 j), détection LAN automatique
|
||||||
|
- Filtrage des AS par groupes configurables (motifs case-insensitive, formulaire admin)
|
||||||
|
- Pattern de log configurable via l'UI (onglet Recherches) avec support glob
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- Permissions rsync : `--chmod=Fug+rw,Fo-w` assure la lisibilité groupe sur les fichiers déployés
|
||||||
|
- `saveSiteSettings()` et `saveSmtpSettings()` : retournent un `bool` et affichent une erreur si l'écriture échoue
|
||||||
|
- `scripts/setup.sh` : script d'initialisation Folio (composer, répertoires, droits, migrations, groupe `adm`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.2] - 2026-05-14
|
## [1.2.2] - 2026-05-14
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Ce qu'est ce dépôt
|
||||||
|
|
||||||
|
**Folio** est un moteur de blog PHP.
|
||||||
|
Ce répertoire est la **copie locale du dépôt Git** (`https://git.abonnel.fr/cedricAbonnel/folio`), branche DEV.
|
||||||
|
Il contient uniquement le code du moteur — pas de données, pas de credentials.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Répertoire local | Site distant | Rôle |
|
||||||
|
|-----------------|-------------|------|
|
||||||
|
| `~/Projects/folio/` | — | Copie du dépôt Folio (branche DEV). On code ici. |
|
||||||
|
| `~/Projects/varlog/` | varlog.a5l.fr | Sync bidirectionnelle des articles varlog. Sert de site de test pour le moteur. |
|
||||||
|
| `~/Projects/fr.abonnel.www/` | www.abonnel.fr | Sync bidirectionnelle des articles abonnel.fr. A aussi servi au déploiement initial. |
|
||||||
|
|
||||||
|
**abonnel.fr** utilise Folio mais se met à jour seul via son UpdateChecker interne (vérifie `version.txt` sur Gitea). Aucune action manuelle nécessaire côté serveur.
|
||||||
|
|
||||||
|
## Articles (`data/`)
|
||||||
|
|
||||||
|
Les articles ne sont pas versionnés dans ce dépôt. Ils ont leur propre git local dans chaque workspace site (`~/Projects/varlog/data/`, `~/Projects/fr.abonnel.www/data/`), synchronisé de façon bidirectionnelle avec le serveur distant.
|
||||||
|
|
||||||
|
## Modifier le moteur
|
||||||
|
|
||||||
|
Pour toute correction ou fonctionnalité : **créer un ticket et une PR**.
|
||||||
|
|
||||||
|
1. Coder ici dans `~/Projects/folio/` (branche feature)
|
||||||
|
2. **Tester sur varlog.a5l.fr** :
|
||||||
|
```bash
|
||||||
|
~/Projects/varlog/scripts/sync.sh
|
||||||
|
# puis tester sur http://varlog.acegrp.lan
|
||||||
|
```
|
||||||
|
3. Une fois validé, ouvrir une PR sur Gitea. Le commit doit inclure :
|
||||||
|
- `public/version.txt` (bump semver)
|
||||||
|
- `CHANGELOG.md` (entrée `### Ajouté / Corrigé / Modifié`)
|
||||||
|
4. Merger la PR → abonnel.fr se met à jour automatiquement.
|
||||||
|
|
||||||
|
## Données articles (`DATA_PATH`)
|
||||||
|
|
||||||
|
Les articles sont stockés dans un répertoire **hors du dépôt Folio**, configurable via `DATA_PATH` dans `.env`.
|
||||||
|
|
||||||
|
| Environnement | Chemin local | Chemin serveur |
|
||||||
|
|--------------|-------------|----------------|
|
||||||
|
| varlog | `~/Projects/varlog-data/` | `/srv/data/folio` |
|
||||||
|
| abonnel.fr | `~/Projects/fr.abonnel.www-data/` | `/srv/data/folio` |
|
||||||
|
|
||||||
|
Les scripts de sync (`pull-data.sh`, `push-data.sh`, `sync.sh`) utilisent `DATA_DIR` (overridable via env) pointant vers ces chemins locaux.
|
||||||
|
|
||||||
|
## Asymétrie de déploiement moteur
|
||||||
|
|
||||||
|
| Site | Mécanisme | Raison |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| varlog (test) | rsync depuis `~/Projects/folio/` | Itération rapide, pas de contrainte de stabilité |
|
||||||
|
| abonnel.fr (prod) | `git pull origin main` sur le serveur | Contrôle via PR/merge, UpdateChecker autonome |
|
||||||
|
|
||||||
|
Pour initialiser git sur un serveur abonnel.fr déployé via rsync : `scripts/git-init-remote.sh`
|
||||||
|
|
||||||
|
## Ne pas mettre ici
|
||||||
|
|
||||||
|
- `.env` (credentials → dans chaque workspace site)
|
||||||
|
- `data/` (articles → dans chaque workspace site)
|
||||||
|
- `vendor/` (non versionné)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# FOLIO
|
||||||
|
|
||||||
|
Moteur de blog PHP — utilisé par plusieurs sites.
|
||||||
|
|
||||||
|
## Dépôt
|
||||||
|
|
||||||
|
`https://git.abonnel.fr/cedricAbonnel/folio` — branche `main`
|
||||||
|
|
||||||
|
## Sites utilisant Folio
|
||||||
|
|
||||||
|
| Site | Workspace local | Serveur |
|
||||||
|
|---|---|---|
|
||||||
|
| varlog.a5l.fr | `~/Projects/varlog/` | `ssh varlog` |
|
||||||
|
| www.abonnel.fr | `~/Projects/fr.abonnel.www/` | `ssh abonnel-wiki` |
|
||||||
|
|
||||||
|
## Structure du moteur
|
||||||
|
|
||||||
|
```
|
||||||
|
folio/
|
||||||
|
├── src/ Classes PHP (ArticleManager, PostManager, auth…)
|
||||||
|
├── public/ Point d'entrée web (index.php, route.php, assets/)
|
||||||
|
├── templates/ Vues PHP (layout, header, footer, post_*)
|
||||||
|
├── config/ Configuration (config.php)
|
||||||
|
├── database/ Schéma SQL + migrate.php
|
||||||
|
├── composer.json
|
||||||
|
└── CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow de modification du moteur
|
||||||
|
|
||||||
|
### 1. Développement et test sur varlog.a5l.fr
|
||||||
|
|
||||||
|
Modifier le code ici dans `~/Projects/folio/`, tester sur **varlog.a5l.fr** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Déployer sur varlog pour test
|
||||||
|
~/Projects/varlog/scripts/sync.sh
|
||||||
|
|
||||||
|
# Tester sur http://varlog.acegrp.lan (ou https://varlog.a5l.fr)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Validation
|
||||||
|
|
||||||
|
Une fois validé sur varlog.a5l.fr :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commiter sur le serveur varlog (git de déploiement)
|
||||||
|
~/Projects/varlog/scripts/commit.sh "description du changement"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Push vers le dépôt Folio
|
||||||
|
|
||||||
|
Pousser le code validé vers le dépôt canonique Folio :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/folio
|
||||||
|
./scripts/push.sh "description du changement"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Déployer sur les autres sites si nécessaire
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/Projects/fr.abonnel.www/scripts/sync.sh
|
||||||
|
~/Projects/fr.abonnel.www/scripts/commit.sh "même message"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credentials locaux
|
||||||
|
|
||||||
|
Aucun credential dans folio/ — les `.env` sont dans chaque workspace site.
|
||||||
@@ -6,6 +6,12 @@ if (!defined('BASE_PATH')) {
|
|||||||
define('BASE_PATH', __DIR__);
|
define('BASE_PATH', __DIR__);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!defined('DATA_PATH')) {
|
||||||
|
$__dataPath = $_ENV['DATA_PATH'] ?? getenv('DATA_PATH') ?: '';
|
||||||
|
define('DATA_PATH', $__dataPath !== '' ? rtrim($__dataPath, '/') : BASE_PATH . '/data');
|
||||||
|
unset($__dataPath);
|
||||||
|
}
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||||
$sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null);
|
$sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Schéma initial : tables créées avant la mise en place du système de migrations.
|
||||||
|
-- Remplace tables_create.sql et interactions_create.sql.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
is_published BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_files (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
file_type TEXT,
|
||||||
|
file_path TEXT,
|
||||||
|
original_name TEXT,
|
||||||
|
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS article_reactions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
article_uuid TEXT NOT NULL,
|
||||||
|
reaction_type TEXT NOT NULL CHECK (reaction_type IN ('useful', 'important', 'interesting')),
|
||||||
|
visitor_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (article_uuid, reaction_type, visitor_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS article_reactions_article_uuid_idx ON article_reactions (article_uuid);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
article_uuid TEXT NOT NULL,
|
||||||
|
author_name TEXT NOT NULL,
|
||||||
|
author_email TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL CHECK (LENGTH(content) <= 2000),
|
||||||
|
verify_token TEXT,
|
||||||
|
verification_code TEXT,
|
||||||
|
verify_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS comments_article_uuid_idx ON comments (article_uuid, verified, published);
|
||||||
|
CREATE INDEX IF NOT EXISTS comments_verify_token_idx ON comments (verify_token)
|
||||||
|
WHERE verified = FALSE AND verify_token IS NOT NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
email TEXT NOT NULL PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT now(),
|
||||||
|
profile_url TEXT NOT NULL DEFAULT '',
|
||||||
|
profile_slug TEXT NOT NULL DEFAULT '',
|
||||||
|
bio TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_profile_slug_idx
|
||||||
|
ON user_profiles (profile_slug) WHERE profile_slug <> '';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS journal_smtp (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
script_path VARCHAR(512),
|
||||||
|
to_email VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(512),
|
||||||
|
content_html TEXT,
|
||||||
|
content_text TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'queued',
|
||||||
|
ip VARCHAR(128),
|
||||||
|
user_agent VARCHAR(512),
|
||||||
|
error_message VARCHAR(1000),
|
||||||
|
sent_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_journal_smtp_created_at ON journal_smtp (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_journal_smtp_to_email ON journal_smtp (to_email);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS role_capabilities (
|
||||||
|
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
capability VARCHAR(50) NOT NULL,
|
||||||
|
PRIMARY KEY (role_id, capability)
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_capabilities (
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
capability TEXT NOT NULL,
|
||||||
|
granted_by TEXT,
|
||||||
|
granted_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_email, capability)
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
password_changed_at TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT,
|
||||||
|
permissions JSONB NOT NULL DEFAULT '[]',
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
allow_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
allow_oidc BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
registrations_open BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
oidc_issuer TEXT,
|
||||||
|
oidc_name TEXT,
|
||||||
|
oidc_client_id TEXT,
|
||||||
|
oidc_client_secret TEXT,
|
||||||
|
oidc_redirect_uri TEXT,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
CONSTRAINT app_config_single_row CHECK (id = 1)
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS mail_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
to_email TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
available_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
locked_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_queue_pending
|
||||||
|
ON mail_queue (available_at ASC, id ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Tables du dictionnaire de données (formulaires dynamiques)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dd_entities (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dd_fields (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
field_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
ui_order INTEGER,
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
default_val TEXT,
|
||||||
|
UNIQUE (entity_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dd_rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES dd_entities(id) ON DELETE CASCADE,
|
||||||
|
rule_type TEXT NOT NULL,
|
||||||
|
expression TEXT,
|
||||||
|
message TEXT,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dd_enums (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dd_enum_values (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
enum_id INTEGER NOT NULL REFERENCES dd_enums(id) ON DELETE CASCADE,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE (enum_id, code)
|
||||||
|
);
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
define('BASE_PATH', __DIR__);
|
define('BASE_PATH', __DIR__);
|
||||||
|
define('DATA_PATH', BASE_PATH . '/data');
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ require_once BASE_PATH . '/src/Parsedown.php';
|
|||||||
|
|
||||||
const FEED_PAGE_SIZE = 20;
|
const FEED_PAGE_SIZE = 20;
|
||||||
|
|
||||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
$articles = new ArticleManager(DATA_PATH);
|
||||||
$privateCats = $articles->getPrivateCategories();
|
$privateCats = $articles->getPrivateCategories();
|
||||||
$Parsedown = new Parsedown();
|
$Parsedown = new Parsedown();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ if ($name === '' || $name[0] === '.') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = BASE_PATH . '/data/' . $uuid . '/files/' . $name;
|
$path = DATA_PATH . '/' . $uuid . '/files/' . $name;
|
||||||
|
|
||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
|
|||||||
+25
-13
@@ -24,12 +24,14 @@ require_once BASE_PATH . '/src/auth.php';
|
|||||||
require_once BASE_PATH . '/src/SiteSettings.php';
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
require_once BASE_PATH . '/src/ArticleManager.php';
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
require_once BASE_PATH . '/src/BookManager.php';
|
require_once BASE_PATH . '/src/BookManager.php';
|
||||||
|
require_once BASE_PATH . '/src/DataGit.php';
|
||||||
|
|
||||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
$_dataGit = new DataGit(DATA_PATH);
|
||||||
$books = new BookManager(BASE_PATH . '/data/books');
|
$articles = new ArticleManager(DATA_PATH, $_dataGit);
|
||||||
|
$books = new BookManager(DATA_PATH . '/books', $_dataGit);
|
||||||
|
|
||||||
// ─── Mode maintenance ──────────────────────────────────────────────────────
|
// ─── Mode maintenance ──────────────────────────────────────────────────────
|
||||||
if (file_exists(BASE_PATH . '/data/.maintenance')) {
|
if (file_exists(DATA_PATH . '/.maintenance')) {
|
||||||
http_response_code(503);
|
http_response_code(503);
|
||||||
header('Retry-After: 60');
|
header('Retry-After: 60');
|
||||||
include BASE_PATH . '/templates/maintenance.php';
|
include BASE_PATH . '/templates/maintenance.php';
|
||||||
@@ -37,7 +39,7 @@ if (file_exists(BASE_PATH . '/data/.maintenance')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
require_once BASE_PATH . '/src/UpdateChecker.php';
|
require_once BASE_PATH . '/src/UpdateChecker.php';
|
||||||
$_updateChecker = new UpdateChecker(BASE_PATH . '/data', BASE_PATH);
|
$_updateChecker = new UpdateChecker(DATA_PATH, BASE_PATH);
|
||||||
|
|
||||||
$action = $_GET['action'] ?? 'list';
|
$action = $_GET['action'] ?? 'list';
|
||||||
$uuid = $_GET['uuid'] ?? '';
|
$uuid = $_GET['uuid'] ?? '';
|
||||||
@@ -78,7 +80,7 @@ function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
|||||||
// ─── Pages statiques depuis data/site/ ──────────────────────────────────────
|
// ─── Pages statiques depuis data/site/ ──────────────────────────────────────
|
||||||
function loadSitePageData(string $slug): array
|
function loadSitePageData(string $slug): array
|
||||||
{
|
{
|
||||||
$base = BASE_PATH . '/data/site';
|
$base = DATA_PATH . '/site';
|
||||||
$meta = [];
|
$meta = [];
|
||||||
$raw = @file_get_contents($base . '/' . $slug . '.json');
|
$raw = @file_get_contents($base . '/' . $slug . '.json');
|
||||||
if ($raw !== false) {
|
if ($raw !== false) {
|
||||||
@@ -1383,7 +1385,7 @@ switch ($action) {
|
|||||||
|
|
||||||
case 'flux':
|
case 'flux':
|
||||||
require_once BASE_PATH . '/src/FeedFetcher.php';
|
require_once BASE_PATH . '/src/FeedFetcher.php';
|
||||||
$fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds');
|
$fetcher = new FeedFetcher(DATA_PATH . '/_cache/feeds');
|
||||||
$fluxItems = [];
|
$fluxItems = [];
|
||||||
$pdo = dbPdo();
|
$pdo = dbPdo();
|
||||||
if ($pdo) {
|
if ($pdo) {
|
||||||
@@ -1535,8 +1537,8 @@ switch ($action) {
|
|||||||
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
|
echo json_encode(['ok' => false, 'error' => 'Paramètres invalides']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$cfSrc = BASE_PATH . '/data/' . $cfFrom . '/files/' . $cfName;
|
$cfSrc = DATA_PATH . '/' . $cfFrom . '/files/' . $cfName;
|
||||||
$cfDstDir = BASE_PATH . '/data/' . $cfTo . '/files';
|
$cfDstDir = DATA_PATH . '/' . $cfTo . '/files';
|
||||||
$cfDst = $cfDstDir . '/' . $cfName;
|
$cfDst = $cfDstDir . '/' . $cfName;
|
||||||
if (!file_exists($cfSrc)) {
|
if (!file_exists($cfSrc)) {
|
||||||
echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']);
|
echo json_encode(['ok' => false, 'error' => 'Fichier source introuvable']);
|
||||||
@@ -1649,7 +1651,7 @@ switch ($action) {
|
|||||||
// Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement)
|
// Capture d'écran pour prévisualisation (pages HTML uniquement, URL externes uniquement)
|
||||||
$step2Screenshot = null;
|
$step2Screenshot = null;
|
||||||
if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) {
|
if (!$step2IsInternal && str_starts_with($step2Meta['mime'] ?? '', 'text/html')) {
|
||||||
$filesDir = BASE_PATH . '/data/' . $uuid . '/files';
|
$filesDir = DATA_PATH . '/' . $uuid . '/files';
|
||||||
if (!is_dir($filesDir)) {
|
if (!is_dir($filesDir)) {
|
||||||
mkdir($filesDir, 0755, true);
|
mkdir($filesDir, 0755, true);
|
||||||
}
|
}
|
||||||
@@ -1725,7 +1727,7 @@ switch ($action) {
|
|||||||
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$filesDir = BASE_PATH . '/data/' . $urlUuid . '/files';
|
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
|
||||||
$previewPath = $filesDir . '/' . $screenshotFile;
|
$previewPath = $filesDir . '/' . $screenshotFile;
|
||||||
if (!file_exists($previewPath)) {
|
if (!file_exists($previewPath)) {
|
||||||
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
header('Location: /import/' . rawurlencode($urlUuid) . '?error=1');
|
||||||
@@ -1744,7 +1746,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($mode === 'link') {
|
if ($mode === 'link') {
|
||||||
$filesDir = BASE_PATH . '/data/' . $urlUuid . '/files';
|
$filesDir = DATA_PATH . '/' . $urlUuid . '/files';
|
||||||
if (!is_dir($filesDir)) {
|
if (!is_dir($filesDir)) {
|
||||||
mkdir($filesDir, 0755, true);
|
mkdir($filesDir, 0755, true);
|
||||||
}
|
}
|
||||||
@@ -1895,7 +1897,7 @@ switch ($action) {
|
|||||||
$done = $fail = $skip = 0;
|
$done = $fail = $skip = 0;
|
||||||
foreach ($articles->getAll() as $article) {
|
foreach ($articles->getAll() as $article) {
|
||||||
$artUuid = $article['uuid'];
|
$artUuid = $article['uuid'];
|
||||||
$filesDir = BASE_PATH . '/data/' . $artUuid . '/files';
|
$filesDir = DATA_PATH . '/' . $artUuid . '/files';
|
||||||
foreach ($article['external_links'] ?? [] as $link) {
|
foreach ($article['external_links'] ?? [] as $link) {
|
||||||
$lMeta = $link['meta'] ?? [];
|
$lMeta = $link['meta'] ?? [];
|
||||||
$lMime = $lMeta['mime'] ?? 'text/html';
|
$lMime = $lMeta['mime'] ?? 'text/html';
|
||||||
@@ -2784,7 +2786,7 @@ switch ($action) {
|
|||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
$_cmDataDir = BASE_PATH . '/data';
|
$_cmDataDir = DATA_PATH;
|
||||||
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
|
$_cmTrack = $_cmDataDir . '/.content_migrations.json';
|
||||||
$_cmFlag = $_cmDataDir . '/.maintenance';
|
$_cmFlag = $_cmDataDir . '/.maintenance';
|
||||||
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
|
$_cmApplied = file_exists($_cmTrack) ? (json_decode((string) file_get_contents($_cmTrack), true) ?? []) : [];
|
||||||
@@ -2814,6 +2816,16 @@ switch ($action) {
|
|||||||
header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
|
header('Location: /admin?tab=dashboard¬ice=' . ($_cmErrors ? 'migration_error' : 'migrated'));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'force_update_check':
|
||||||
|
requireAuth();
|
||||||
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_updateChecker->clearCache();
|
||||||
|
header('Location: /admin?tab=dashboard');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_save_site':
|
case 'admin_save_site':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ require_once BASE_PATH . '/src/helpers.php';
|
|||||||
require_once BASE_PATH . '/config/config.php';
|
require_once BASE_PATH . '/config/config.php';
|
||||||
require_once BASE_PATH . '/src/ArticleManager.php';
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
|
|
||||||
$articles = new ArticleManager(BASE_PATH . '/data');
|
$articles = new ArticleManager(DATA_PATH);
|
||||||
$privateCats = $articles->getPrivateCategories();
|
$privateCats = $articles->getPrivateCategories();
|
||||||
|
|
||||||
$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool {
|
$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.2
|
1.4.0
|
||||||
|
|||||||
+52
-11
@@ -9,7 +9,7 @@ class ArticleManager
|
|||||||
private ?array $allCache = null;
|
private ?array $allCache = null;
|
||||||
private ?array $searchIndexCache = null;
|
private ?array $searchIndexCache = null;
|
||||||
|
|
||||||
public function __construct(private string $dataDir)
|
public function __construct(private string $dataDir, private ?DataGit $git = null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +132,12 @@ class ArticleManager
|
|||||||
file_put_contents($dir . '/index.md', ltrim($content));
|
file_put_contents($dir . '/index.md', ltrim($content));
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
|
$this->git?->commit("add: $title");
|
||||||
|
|
||||||
return $uuid;
|
return $uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null): void
|
public function update(string $uuid, string $title, string $content, bool $published, string $slug, string $publishedAt, string $revisionComment = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', ?array $tags = null, bool $skipGit = false): void
|
||||||
{
|
{
|
||||||
$article = $this->getByUuid($uuid);
|
$article = $this->getByUuid($uuid);
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
@@ -199,6 +200,9 @@ class ArticleManager
|
|||||||
file_put_contents($dir . '/index.md', ltrim($content));
|
file_put_contents($dir . '/index.md', ltrim($content));
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
|
if (!$skipGit) {
|
||||||
|
$this->git?->commit("update: $title");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function autosave(string $uuid, string $title, string $content, string $slug): bool
|
public function autosave(string $uuid, string $title, string $content, string $slug): bool
|
||||||
@@ -247,6 +251,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$meta['updated_at'] = date('Y-m-d H:i:s');
|
$meta['updated_at'] = date('Y-m-d H:i:s');
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
|
$this->git?->commit("meta: " . ($meta['title'] ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
|
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
|
||||||
@@ -269,6 +274,9 @@ class ArticleManager
|
|||||||
if ($content !== null) {
|
if ($content !== null) {
|
||||||
file_put_contents($dir . '/draft_overlay.md', $content);
|
file_put_contents($dir . '/draft_overlay.md', $content);
|
||||||
}
|
}
|
||||||
|
$raw2 = @file_get_contents($dir . '/meta.json');
|
||||||
|
$title = is_string($raw2) ? (json_decode($raw2, true)['title'] ?? $uuid) : $uuid;
|
||||||
|
$this->git?->commit("draft: $title");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDraftOverlay(string $uuid): ?array
|
public function getDraftOverlay(string $uuid): ?array
|
||||||
@@ -315,14 +323,22 @@ class ArticleManager
|
|||||||
return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json');
|
return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function discardDraftOverlay(string $uuid): void
|
public function discardDraftOverlay(string $uuid, bool $skipGit = false): void
|
||||||
{
|
{
|
||||||
if (!$this->isValidUuid($uuid)) {
|
if (!$this->isValidUuid($uuid)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$dir = $this->dataDir . '/' . $uuid;
|
$dir = $this->dataDir . '/' . $uuid;
|
||||||
|
$title = null;
|
||||||
|
if (!$skipGit && $this->git !== null) {
|
||||||
|
$raw = @file_get_contents($dir . '/meta.json');
|
||||||
|
$title = is_string($raw) ? (json_decode($raw, true)['title'] ?? $uuid) : $uuid;
|
||||||
|
}
|
||||||
@unlink($dir . '/draft_overlay.json');
|
@unlink($dir . '/draft_overlay.json');
|
||||||
@unlink($dir . '/draft_overlay.md');
|
@unlink($dir . '/draft_overlay.md');
|
||||||
|
if ($title !== null) {
|
||||||
|
$this->git?->commit("discard-draft: $title");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void
|
public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void
|
||||||
@@ -331,9 +347,10 @@ class ArticleManager
|
|||||||
if (!$draft) {
|
if (!$draft) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
$title = $draft['title'];
|
||||||
$this->update(
|
$this->update(
|
||||||
$uuid,
|
$uuid,
|
||||||
$draft['title'],
|
$title,
|
||||||
$draft['content'],
|
$draft['content'],
|
||||||
(bool)$draft['published'],
|
(bool)$draft['published'],
|
||||||
$draft['slug'] ?? '',
|
$draft['slug'] ?? '',
|
||||||
@@ -343,12 +360,14 @@ class ArticleManager
|
|||||||
$draft['seo_description'] ?? '',
|
$draft['seo_description'] ?? '',
|
||||||
$draft['og_image'] ?? '',
|
$draft['og_image'] ?? '',
|
||||||
$draft['category'] ?? '',
|
$draft['category'] ?? '',
|
||||||
$draft['tags'] ?? []
|
$draft['tags'] ?? [],
|
||||||
|
true // skipGit — commit unique ci-dessous
|
||||||
);
|
);
|
||||||
$this->discardDraftOverlay($uuid);
|
$this->discardDraftOverlay($uuid, skipGit: true);
|
||||||
|
$this->git?->commit("publish: $title");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void
|
public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = [], bool $skipGit = false): void
|
||||||
{
|
{
|
||||||
if (!$this->isValidUuid($uuid)) {
|
if (!$this->isValidUuid($uuid)) {
|
||||||
return;
|
return;
|
||||||
@@ -377,6 +396,9 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$meta['files_meta'][$filename] = $entry;
|
$meta['files_meta'][$filename] = $entry;
|
||||||
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
||||||
|
if (!$skipGit) {
|
||||||
|
$this->git?->commit("file-meta: {$uuid}/{$filename}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setCover(string $uuid, string $filename): void
|
public function setCover(string $uuid, string $filename): void
|
||||||
@@ -424,6 +446,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$meta['cover'] = $coverName;
|
$meta['cover'] = $coverName;
|
||||||
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
|
||||||
|
$this->git?->commit("cover: " . ($article['title'] ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string
|
public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string
|
||||||
@@ -499,7 +522,7 @@ class ArticleManager
|
|||||||
rename($tmp, $filesDir . '/' . $filename);
|
rename($tmp, $filesDir . '/' . $filename);
|
||||||
|
|
||||||
if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) {
|
if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) {
|
||||||
$this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta);
|
$this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta, skipGit: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($isCover && $isImage) {
|
if ($isCover && $isImage) {
|
||||||
@@ -513,6 +536,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->git?->commit("add-file: {$uuid}/{$filename}");
|
||||||
return $filename;
|
return $filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +577,7 @@ class ArticleManager
|
|||||||
$meta['external_links'][] = $entry;
|
$meta['external_links'][] = $entry;
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
|
$this->git?->commit("link: {$uuid}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +608,7 @@ class ArticleManager
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
|
$this->git?->commit("link-meta: {$uuid}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,6 +632,7 @@ class ArticleManager
|
|||||||
));
|
));
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
|
$this->git?->commit("unlink: {$uuid}");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +650,7 @@ class ArticleManager
|
|||||||
return $cats;
|
return $cats;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renameCategory(string $old, string $new): void
|
public function renameCategory(string $old, string $new, bool $skipGit = false): void
|
||||||
{
|
{
|
||||||
if (!is_dir($this->dataDir)) {
|
if (!is_dir($this->dataDir)) {
|
||||||
return;
|
return;
|
||||||
@@ -647,11 +674,15 @@ class ArticleManager
|
|||||||
$meta['category'] = $new;
|
$meta['category'] = $new;
|
||||||
$this->writeMeta($this->dataDir . '/' . $entry, $meta);
|
$this->writeMeta($this->dataDir . '/' . $entry, $meta);
|
||||||
}
|
}
|
||||||
|
if (!$skipGit) {
|
||||||
|
$this->git?->commit("rename-cat: $old → $new");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteCategory(string $name): void
|
public function deleteCategory(string $name): void
|
||||||
{
|
{
|
||||||
$this->renameCategory($name, '');
|
$this->renameCategory($name, '', skipGit: true);
|
||||||
|
$this->git?->commit("delete-cat: $name");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPrivateCategories(): array
|
public function getPrivateCategories(): array
|
||||||
@@ -676,6 +707,7 @@ class ArticleManager
|
|||||||
$this->dataDir . '/private_cats.json',
|
$this->dataDir . '/private_cats.json',
|
||||||
json_encode(array_values($cats), JSON_UNESCAPED_UNICODE)
|
json_encode(array_values($cats), JSON_UNESCAPED_UNICODE)
|
||||||
);
|
);
|
||||||
|
$this->git?->commit("private-cat: $cat");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tag types ──────────────────────────────────────────────────────────────
|
// ─── Tag types ──────────────────────────────────────────────────────────────
|
||||||
@@ -701,6 +733,7 @@ class ArticleManager
|
|||||||
$this->tagTypesPath(),
|
$this->tagTypesPath(),
|
||||||
json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
|
json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"
|
||||||
);
|
);
|
||||||
|
$this->git?->commit("tag-types");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Enregistre les tags d'un article directement (utile pour les scripts de migration). */
|
/** Enregistre les tags d'un article directement (utile pour les scripts de migration). */
|
||||||
@@ -720,6 +753,7 @@ class ArticleManager
|
|||||||
$meta['tags'] = $this->normalizeTags($tags);
|
$meta['tags'] = $this->normalizeTags($tags);
|
||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
|
$this->git?->commit("tags: " . ($meta['title'] ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> Toutes les valeurs distinctes d'un type de tag, triées. */
|
/** @return list<string> Toutes les valeurs distinctes d'un type de tag, triées. */
|
||||||
@@ -769,6 +803,7 @@ class ArticleManager
|
|||||||
$this->writeMeta($dir, $meta);
|
$this->writeMeta($dir, $meta);
|
||||||
$this->allCache = null;
|
$this->allCache = null;
|
||||||
@unlink($this->articleCachePath($uuid));
|
@unlink($this->articleCachePath($uuid));
|
||||||
|
$this->git?->commit("featured: " . ($meta['title'] ?? $uuid) . " (" . ($featured ? 'on' : 'off') . ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $uuid): void
|
public function delete(string $uuid): void
|
||||||
@@ -777,6 +812,11 @@ class ArticleManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$dir = $this->dataDir . '/' . $uuid;
|
$dir = $this->dataDir . '/' . $uuid;
|
||||||
|
$title = null;
|
||||||
|
if ($this->git !== null && is_dir($dir)) {
|
||||||
|
$raw = @file_get_contents($dir . '/meta.json');
|
||||||
|
$title = is_string($raw) ? (json_decode($raw, true)['title'] ?? null) : null;
|
||||||
|
}
|
||||||
if (is_dir($dir)) {
|
if (is_dir($dir)) {
|
||||||
$this->allCache = null;
|
$this->allCache = null;
|
||||||
@unlink($this->articleCachePath($uuid));
|
@unlink($this->articleCachePath($uuid));
|
||||||
@@ -785,6 +825,7 @@ class ArticleManager
|
|||||||
}
|
}
|
||||||
$this->rebuildSearchIndex();
|
$this->rebuildSearchIndex();
|
||||||
$this->rebuildBacklinksCache();
|
$this->rebuildBacklinksCache();
|
||||||
|
$this->git?->commit("delete: " . ($title ?? $uuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|||||||
+5
-2
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
class BookManager
|
class BookManager
|
||||||
{
|
{
|
||||||
public function __construct(private string $booksDir)
|
public function __construct(private string $booksDir, private ?DataGit $git = null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +95,17 @@ class BookManager
|
|||||||
$this->bookPath($slug),
|
$this->bookPath($slug),
|
||||||
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
);
|
);
|
||||||
|
$this->git?->commit("book: " . ($book['title'] ?? $slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $slug): void
|
public function delete(string $slug): void
|
||||||
{
|
{
|
||||||
$path = $this->bookPath($slug);
|
$title = $this->getBySlug($slug)['title'] ?? $slug;
|
||||||
|
$path = $this->bookPath($slug);
|
||||||
if (file_exists($path)) {
|
if (file_exists($path)) {
|
||||||
@unlink($path);
|
@unlink($path);
|
||||||
}
|
}
|
||||||
|
$this->git?->commit("delete-book: $title");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class DataGit
|
||||||
|
{
|
||||||
|
public function __construct(private string $dataDir) {}
|
||||||
|
|
||||||
|
public function commit(string $message): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->dataDir . '/.git')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$dir = escapeshellarg($this->dataDir);
|
||||||
|
$msg = escapeshellarg($message);
|
||||||
|
shell_exec("git -C $dir add -A 2>/dev/null");
|
||||||
|
exec("git -C $dir diff --cached --quiet 2>/dev/null", $_, $rc);
|
||||||
|
if ($rc !== 0) {
|
||||||
|
shell_exec("git -C $dir commit -m $msg 2>/dev/null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
function siteSettingsPath(): string
|
function siteSettingsPath(): string
|
||||||
{
|
{
|
||||||
return BASE_PATH . '/data/site_settings.json';
|
return DATA_PATH . '/site_settings.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
function siteSettings(): array
|
function siteSettings(): array
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
function smtpSettingsPath(): string
|
function smtpSettingsPath(): string
|
||||||
{
|
{
|
||||||
return BASE_PATH . '/data/smtp_settings.json';
|
return DATA_PATH . '/smtp_settings.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
function smtpSettings(): array
|
function smtpSettings(): array
|
||||||
|
|||||||
+27
-3
@@ -89,8 +89,31 @@ class UpdateChecker
|
|||||||
return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null;
|
return version_compare($remoteVer, $deployedVer, '>') ? $remoteVer : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBranch(): string
|
||||||
|
{
|
||||||
|
return (string) ($_ENV['FOLIO_UPDATE_BRANCH'] ?? getenv('FOLIO_UPDATE_BRANCH') ?: 'main');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastChecked(): ?int
|
||||||
|
{
|
||||||
|
$cacheFile = $this->dataDir . '/.version_check_cache.json';
|
||||||
|
if (!file_exists($cacheFile)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$cache = json_decode((string) file_get_contents($cacheFile), true) ?? [];
|
||||||
|
return isset($cache['fetched_at']) ? (int) $cache['fetched_at'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
$cacheFile = $this->dataDir . '/.version_check_cache.json';
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
unlink($cacheFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère `public/version.txt` depuis le dépôt Gitea (branche main).
|
* Récupère `public/version.txt` depuis le dépôt Gitea.
|
||||||
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
|
* Résultat mis en cache 1 h dans `data/.version_check_cache.json`.
|
||||||
*/
|
*/
|
||||||
private function fetchRemoteVersion(string $repoUrl): ?string
|
private function fetchRemoteVersion(string $repoUrl): ?string
|
||||||
@@ -107,8 +130,9 @@ class UpdateChecker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL du fichier brut : {repo}/raw/branch/main/public/version.txt
|
$branch = $this->getBranch();
|
||||||
$rawUrl = $repoUrl . '/raw/branch/main/public/version.txt';
|
// URL du fichier brut : {repo}/raw/branch/{branch}/public/version.txt
|
||||||
|
$rawUrl = $repoUrl . '/raw/branch/' . $branch . '/public/version.txt';
|
||||||
|
|
||||||
$token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: '');
|
$token = (string) ($_ENV['GITEA_TOKEN'] ?? getenv('GITEA_TOKEN') ?: '');
|
||||||
$opts = [
|
$opts = [
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
// Page d'erreur 404 — à inclure après http_response_code(404).
|
||||||
|
// Aucune variable externe requise.
|
||||||
|
$title = '404 — ' . siteTitle();
|
||||||
|
$metaRobots = 'noindex, nofollow';
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="container py-5 text-center">
|
||||||
|
<p class="display-1 fw-bold text-muted mb-0">404</p>
|
||||||
|
<h1 class="h3 mb-3">Page introuvable</h1>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Cette adresse ne correspond à aucun contenu.<br>
|
||||||
|
Vous avez peut-être suivi un ancien lien.
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
include __DIR__ . '/layout.php';
|
||||||
+12
-1
@@ -104,6 +104,8 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
|
$_deployedVer = trim((string) @file_get_contents(BASE_PATH . '/public/version.txt'));
|
||||||
$_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—';
|
$_deployedLabel = $_deployedVer !== '' ? $_deployedVer : '—';
|
||||||
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
|
$_notices = isset($_updateChecker) ? $_updateChecker->adminNotices() : [];
|
||||||
|
$_branch = isset($_updateChecker) ? $_updateChecker->getBranch() : 'main';
|
||||||
|
$_lastChecked = isset($_updateChecker) ? $_updateChecker->getLastChecked() : null;
|
||||||
$_remoteLabel = '—';
|
$_remoteLabel = '—';
|
||||||
foreach ($_notices as $_n) {
|
foreach ($_notices as $_n) {
|
||||||
if ($_n['type'] === 'info' && preg_match('/v([\d]+\.[\d]+\.[\d]+)/', $_n['message'], $_m)) {
|
if ($_n['type'] === 'info' && preg_match('/v([\d]+\.[\d]+\.[\d]+)/', $_n['message'], $_m)) {
|
||||||
@@ -122,7 +124,16 @@ function adminStatusBadge(array $a, int $now): string
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Dernière version disponible</th>
|
<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>
|
<td class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span><?= htmlspecialchars($_remoteLabel) ?><?= $_remoteLabel !== '—' && $_remoteLabel !== $_deployedLabel ? ' <span class="badge bg-warning text-dark ms-1">Mise à jour disponible</span>' : '' ?></span>
|
||||||
|
<form method="POST" action="/?action=force_update_check" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm py-0">Vérifier</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Branche suivie</th>
|
||||||
|
<td><code><?= htmlspecialchars($_branch) ?></code><?= $_lastChecked !== null ? ' <span class="text-muted ms-2">· vérifié le ' . date('d/m/Y à H:i', $_lastChecked) . '</span>' : '' ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php if (!empty($_notices)): ?>
|
<?php if (!empty($_notices)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ $preSource = $step2Meta['canonical'] ?? $step2Meta['source'] ?? $step2Url;
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p class="fw-semibold small mb-2">Aperçu de la page</p>
|
<p class="fw-semibold small mb-2">Aperçu de la page</p>
|
||||||
<?php
|
<?php
|
||||||
$previewMtime = @filemtime(BASE_PATH . '/data/' . $step2Article['uuid'] . '/files/' . $step2Screenshot) ?: time();
|
$previewMtime = @filemtime(DATA_PATH . '/' . $step2Article['uuid'] . '/files/' . $step2Screenshot) ?: time();
|
||||||
?>
|
?>
|
||||||
<img src="/file?uuid=<?= rawurlencode($step2Article['uuid']) ?>&name=<?= rawurlencode($step2Screenshot) ?>&v=<?= $previewMtime ?>"
|
<img src="/file?uuid=<?= rawurlencode($step2Article['uuid']) ?>&name=<?= rawurlencode($step2Screenshot) ?>&v=<?= $previewMtime ?>"
|
||||||
class="img-fluid rounded shadow-sm d-block"
|
class="img-fluid rounded shadow-sm d-block"
|
||||||
|
|||||||
Reference in New Issue
Block a user