feat : DATA_PATH configurable, DataGit auto-commit, UpdateChecker branche (v1.4.0)

- DATA_PATH : chemin /data hors document root, configurable via .env
  (fallback sur BASE_PATH/data si absent)
- DataGit : auto-commit git sur toutes les écritures articles/livres
  (create, update, delete, meta, tags, fichiers, liens…) sauf autosave
- UpdateChecker : getBranch() / getLastChecked() / clearCache(),
  branche configurable via FOLIO_UPDATE_BRANCH (plus de main hardcodé)
- Admin dashboard : affiche la branche suivie, date du dernier contrôle,
  bouton Vérifier pour forcer le check sans attendre le TTL
- CLAUDE.md : architecture DATA_PATH et flux de déploiement documentés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 09:17:55 +02:00
parent 55a2120be1
commit 16965ee8cb
18 changed files with 236 additions and 37 deletions
+52 -11
View File
@@ -9,7 +9,7 @@ class ArticleManager
private ?array $allCache = 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));
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
$this->git?->commit("add: $title");
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);
if (!$article) {
@@ -199,6 +200,9 @@ class ArticleManager
file_put_contents($dir . '/index.md', ltrim($content));
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
if (!$skipGit) {
$this->git?->commit("update: $title");
}
}
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');
$this->writeMeta($dir, $meta);
$this->git?->commit("meta: " . ($meta['title'] ?? $uuid));
}
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
@@ -269,6 +274,9 @@ class ArticleManager
if ($content !== null) {
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
@@ -315,14 +323,22 @@ class ArticleManager
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)) {
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.md');
if ($title !== null) {
$this->git?->commit("discard-draft: $title");
}
}
public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void
@@ -331,9 +347,10 @@ class ArticleManager
if (!$draft) {
return;
}
$title = $draft['title'];
$this->update(
$uuid,
$draft['title'],
$title,
$draft['content'],
(bool)$draft['published'],
$draft['slug'] ?? '',
@@ -343,12 +360,14 @@ class ArticleManager
$draft['seo_description'] ?? '',
$draft['og_image'] ?? '',
$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)) {
return;
@@ -377,6 +396,9 @@ class ArticleManager
}
$meta['files_meta'][$filename] = $entry;
$this->writeMeta($this->dataDir . '/' . $uuid, $meta);
if (!$skipGit) {
$this->git?->commit("file-meta: {$uuid}/{$filename}");
}
}
public function setCover(string $uuid, string $filename): void
@@ -424,6 +446,7 @@ class ArticleManager
}
$meta['cover'] = $coverName;
$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
@@ -499,7 +522,7 @@ class ArticleManager
rename($tmp, $filesDir . '/' . $filename);
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) {
@@ -513,6 +536,7 @@ class ArticleManager
}
}
$this->git?->commit("add-file: {$uuid}/{$filename}");
return $filename;
}
@@ -553,6 +577,7 @@ class ArticleManager
$meta['external_links'][] = $entry;
$this->writeMeta($dir, $meta);
$this->rebuildBacklinksCache();
$this->git?->commit("link: {$uuid}");
return true;
}
@@ -583,6 +608,7 @@ class ArticleManager
return false;
}
$this->writeMeta($dir, $meta);
$this->git?->commit("link-meta: {$uuid}");
return true;
}
@@ -606,6 +632,7 @@ class ArticleManager
));
$this->writeMeta($dir, $meta);
$this->rebuildBacklinksCache();
$this->git?->commit("unlink: {$uuid}");
return true;
}
@@ -623,7 +650,7 @@ class ArticleManager
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)) {
return;
@@ -647,11 +674,15 @@ class ArticleManager
$meta['category'] = $new;
$this->writeMeta($this->dataDir . '/' . $entry, $meta);
}
if (!$skipGit) {
$this->git?->commit("rename-cat: $old$new");
}
}
public function deleteCategory(string $name): void
{
$this->renameCategory($name, '');
$this->renameCategory($name, '', skipGit: true);
$this->git?->commit("delete-cat: $name");
}
public function getPrivateCategories(): array
@@ -676,6 +707,7 @@ class ArticleManager
$this->dataDir . '/private_cats.json',
json_encode(array_values($cats), JSON_UNESCAPED_UNICODE)
);
$this->git?->commit("private-cat: $cat");
}
// ─── Tag types ──────────────────────────────────────────────────────────────
@@ -701,6 +733,7 @@ class ArticleManager
$this->tagTypesPath(),
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). */
@@ -720,6 +753,7 @@ class ArticleManager
$meta['tags'] = $this->normalizeTags($tags);
$this->writeMeta($dir, $meta);
$this->rebuildSearchIndex();
$this->git?->commit("tags: " . ($meta['title'] ?? $uuid));
}
/** @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->allCache = null;
@unlink($this->articleCachePath($uuid));
$this->git?->commit("featured: " . ($meta['title'] ?? $uuid) . " (" . ($featured ? 'on' : 'off') . ")");
}
public function delete(string $uuid): void
@@ -777,6 +812,11 @@ class ArticleManager
return;
}
$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)) {
$this->allCache = null;
@unlink($this->articleCachePath($uuid));
@@ -785,6 +825,7 @@ class ArticleManager
}
$this->rebuildSearchIndex();
$this->rebuildBacklinksCache();
$this->git?->commit("delete: " . ($title ?? $uuid));
}
// ------------------------------------------------------------------ //
+5 -2
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
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),
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
{
$path = $this->bookPath($slug);
$title = $this->getBySlug($slug)['title'] ?? $slug;
$path = $this->bookPath($slug);
if (file_exists($path)) {
@unlink($path);
}
$this->git?->commit("delete-book: $title");
}
// ------------------------------------------------------------------ //
+22
View File
@@ -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");
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
function siteSettingsPath(): string
{
return BASE_PATH . '/data/site_settings.json';
return DATA_PATH . '/site_settings.json';
}
function siteSettings(): array
+1 -1
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
function smtpSettingsPath(): string
{
return BASE_PATH . '/data/smtp_settings.json';
return DATA_PATH . '/smtp_settings.json';
}
function smtpSettings(): array
+27 -3
View File
@@ -89,8 +89,31 @@ class UpdateChecker
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`.
*/
private function fetchRemoteVersion(string $repoUrl): ?string
@@ -107,8 +130,9 @@ class UpdateChecker
}
}
// URL du fichier brut : {repo}/raw/branch/main/public/version.txt
$rawUrl = $repoUrl . '/raw/branch/main/public/version.txt';
$branch = $this->getBranch();
// 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') ?: '');
$opts = [