allCache === null) { $this->allCache = $this->loadAll(); } if ($publishedOnly) { return array_values(array_filter($this->allCache, fn ($a) => $a['published'])); } return $this->allCache; } private function loadAll(): array { $articles = []; if (!is_dir($this->dataDir)) { return $articles; } foreach (scandir($this->dataDir) as $entry) { if ($entry === '.' || $entry === '..') { continue; } $dir = $this->dataDir . '/' . $entry; if (!is_dir($dir) || !file_exists($dir . '/meta.json')) { continue; } $article = $this->loadArticle($dir); if (!$article) { continue; } $articles[] = $article; } usort($articles, static fn ($a, $b) => strcmp($b['published_at'] ?? '', $a['published_at'] ?? '')); return $articles; } public function getBySlug(string $slug): ?array { $path = $this->slugIndexPath(); if (!file_exists($path)) { $this->buildSlugIndex(); } $index = json_decode((string) file_get_contents($path), true) ?? []; $uuid = $index[$slug] ?? null; return $uuid !== null ? $this->getByUuid($uuid) : null; } public function getByUuid(string $uuid): ?array { if (!$this->isValidUuid($uuid)) { return null; } $dir = $this->dataDir . '/' . $uuid; if (!is_dir($dir) || !file_exists($dir . '/meta.json')) { return null; } return $this->loadArticle($dir); } public function getRevisionContent(string $uuid, int $n): ?string { if (!$this->isValidUuid($uuid) || $n < 1) { return null; } $path = sprintf('%s/%s/revisions/%04d.md', $this->dataDir, $uuid, $n); if (!file_exists($path)) { return null; } $c = file_get_contents($path); return $c !== false ? $c : null; } // ------------------------------------------------------------------ // // Écriture // ------------------------------------------------------------------ // public function create(string $title, string $content, bool $published, string $slug = '', string $publishedAt = '', string $author = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = '', array $tags = []): string { $uuid = $this->generateUuid(); $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); $slug = $this->uniqueSlug($slug, $uuid); $now = date('Y-m-d H:i:s'); $publishedAt = $publishedAt !== '' ? $publishedAt : $now; $dir = $this->dataDir . '/' . $uuid; $this->mkArticleDir($dir); $this->mkArticleDir($dir . '/files'); $meta = [ 'uuid' => $uuid, 'slug' => $slug, 'title' => $title, 'author' => $author, 'published' => $published, 'featured' => false, 'published_at' => $publishedAt, 'created_at' => $now, 'updated_at' => $now, 'revisions' => [], 'cover' => '', 'files_meta' => [], 'external_links' => [], 'seo_title' => $seoTitle, 'seo_description' => $seoDescription, 'og_image' => $ogImage, 'category' => $category, 'tags' => $this->normalizeTags($tags), ]; $this->writeMeta($dir, $meta); 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, bool $skipGit = false): void { $article = $this->getByUuid($uuid); if (!$article) { return; } $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); $slug = $this->uniqueSlug($slug, $uuid); // Snapshot de l'état courant avant écrasement — uniquement si le contenu ou le titre a changé $revisions = $article['revisions'] ?? []; $contentChanged = ltrim($content) !== ($article['content'] ?? ''); $titleChanged = $title !== ($article['title'] ?? ''); if ($contentChanged || $titleChanged) { $revDir = $this->dataDir . '/' . $uuid . '/revisions'; if (!is_dir($revDir)) { $this->mkArticleDir($revDir); } $n = count($revisions) + 1; $revFile = sprintf('%s/%04d.md', $revDir, $n); file_put_contents($revFile, $article['content']); $revisions[] = [ 'n' => $n, 'date' => date('Y-m-d H:i:s'), 'comment' => $revisionComment, 'title' => $article['title'], ]; // Limite à MAX_REVISIONS if (count($revisions) > self::MAX_REVISIONS) { $oldest = array_shift($revisions); @unlink(sprintf('%s/%04d.md', $revDir, (int)($oldest['n'] ?? 0))); } } // fin if ($contentChanged || $titleChanged) $meta = [ 'uuid' => $uuid, 'slug' => $slug, 'title' => $title, 'author' => $article['author'] ?? '', 'published' => $published, 'featured' => (bool)($article['featured'] ?? false), 'published_at' => $publishedAt !== '' ? $publishedAt : ($article['published_at'] ?? date('Y-m-d H:i:s')), 'created_at' => $article['created_at'] ?? date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'), 'revisions' => $revisions, 'cover' => $article['cover'] ?? '', 'files_meta' => $article['files_meta'] ?? [], 'external_links' => $article['external_links'] ?? [], 'seo_title' => $seoTitle, 'seo_description' => $seoDescription, 'og_image' => $ogImage, 'category' => $category, 'tags' => $tags !== null ? $this->normalizeTags($tags) : ($article['tags'] ?? []), ]; $dir = $this->dataDir . '/' . $uuid; $this->writeMeta($dir, $meta); 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 { if (!$this->isValidUuid($uuid)) { return false; } $dir = $this->dataDir . '/' . $uuid; $raw = @file_get_contents($dir . '/meta.json'); if ($raw === false) { return false; } $meta = json_decode($raw, true); if (!is_array($meta)) { return false; } $slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title); $slug = $this->uniqueSlug($slug, $uuid); $meta['title'] = $title; $meta['slug'] = $slug; $meta['updated_at'] = date('Y-m-d H:i:s'); $this->writeMeta($dir, $meta); @file_put_contents($dir . '/index.md', ltrim($content)); return true; } public function updatePartialMeta(string $uuid, array $updates): void { if (!$this->isValidUuid($uuid)) { return; } $dir = $this->dataDir . '/' . $uuid; $raw = @file_get_contents($dir . '/meta.json'); if ($raw === false) { return; } $meta = json_decode($raw, true); if (!is_array($meta)) { return; } foreach ($updates as $key => $value) { $meta[$key] = $value; } $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 { if (!$this->isValidUuid($uuid)) { return; } $dir = $this->dataDir . '/' . $uuid; $existing = []; $raw = @file_get_contents($dir . '/draft_overlay.json'); if ($raw !== false) { $existing = json_decode($raw, true) ?? []; } $overlay = array_merge($existing, $metaFields); $overlay['_updated_at'] = date('Y-m-d H:i:s'); file_put_contents( $dir . '/draft_overlay.json', json_encode($overlay, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" ); 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 { if (!$this->isValidUuid($uuid)) { return null; } $dir = $this->dataDir . '/' . $uuid; if (!file_exists($dir . '/draft_overlay.json')) { return null; } $article = $this->getByUuid($uuid); if (!$article) { return null; } $raw = file_get_contents($dir . '/draft_overlay.json'); if ($raw === false) { return null; } $overlay = json_decode($raw, true); if (!is_array($overlay)) { return null; } $merged = $article; foreach ($overlay as $key => $value) { if (!str_starts_with($key, '_')) { $merged[$key] = $value; } } if (file_exists($dir . '/draft_overlay.md')) { $c = file_get_contents($dir . '/draft_overlay.md'); if ($c !== false) { $merged['content'] = $c; } } return $merged; } public function hasDraftOverlay(string $uuid): bool { if (!$this->isValidUuid($uuid)) { return false; } return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json'); } public function discardDraftOverlay(string $uuid, bool $skipGit = false): void { if (!$this->isValidUuid($uuid)) { return; } $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 { $draft = $this->getDraftOverlay($uuid); if (!$draft) { return; } $title = $draft['title']; $this->update( $uuid, $title, $draft['content'], (bool)$draft['published'], $draft['slug'] ?? '', $draft['published_at'] ?? '', $revisionComment, $draft['seo_title'] ?? '', $draft['seo_description'] ?? '', $draft['og_image'] ?? '', $draft['category'] ?? '', $draft['tags'] ?? [], true // skipGit — commit unique ci-dessous ); $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 = [], bool $skipGit = false): void { if (!$this->isValidUuid($uuid)) { return; } $filename = basename($filename); $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); if ($raw === false) { return; } $meta = json_decode($raw, true); if (!is_array($meta)) { return; } if (!isset($meta['files_meta']) || !is_array($meta['files_meta'])) { $meta['files_meta'] = []; } $entry = ['author' => $author, 'source_url' => $sourceUrl]; if ($title !== '') { $entry['title'] = $title; } if (!empty($extraMeta)) { $clean = $extraMeta; unset($clean['title'], $clean['author'], $clean['credit'], $clean['source']); $entry['meta'] = $clean; } $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 { $article = $this->getByUuid($uuid); if (!$article) { return; } $filename = basename($filename); $filesDir = $this->dataDir . '/' . $uuid . '/files'; $targetPath = $filesDir . '/' . $filename; if (!file_exists($targetPath)) { return; } $mime = mime_content_type($targetPath) ?: ''; $ext = $this->extFromMime($mime) ?? strtolower(pathinfo($filename, PATHINFO_EXTENSION)) ?: 'jpg'; $coverName = 'cover.' . $ext; // Rename old cover back to hash name $oldCover = $article['cover'] ?? ''; if ($oldCover && $oldCover !== $filename && $oldCover !== $coverName) { $oldPath = $filesDir . '/' . basename($oldCover); if (file_exists($oldPath)) { $hash = substr(hash_file('sha256', $oldPath), 0, 16); $size = filesize($oldPath); $oldExt = strtolower(pathinfo($oldCover, PATHINFO_EXTENSION)); rename($oldPath, $filesDir . '/' . "{$hash}-{$size}.{$oldExt}"); } } // Rename target to cover.{ext} $newPath = $filesDir . '/' . $coverName; if ($targetPath !== $newPath) { rename($targetPath, $newPath); } $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); if ($raw === false) { return; } $meta = json_decode($raw, true); if (!is_array($meta)) { return; } $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 { if (!$this->isValidUuid($uuid)) { return null; } if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match('#^https?://#i', $url)) { return null; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => 'varlog/1.0', ]); $body = curl_exec($ch); $info = curl_getinfo($ch); curl_close($ch); if ($body === false || (int)$info['http_code'] !== 200) { return null; } $tmp = tempnam(sys_get_temp_dir(), 'vl_'); file_put_contents($tmp, $body); $mime = mime_content_type($tmp) ?: 'application/octet-stream'; $isImage = str_starts_with($mime, 'image/'); $filesDir = $this->dataDir . '/' . $uuid . '/files'; if (!is_dir($filesDir)) { $this->mkArticleDir($filesDir); } if ($isImage) { $ext = $this->extFromMime($mime) ?? strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION)) ?: 'jpg'; if ($isCover) { // Gérer l'ancienne cover $article = $this->getByUuid($uuid); $oldCover = $article['cover'] ?? ''; if ($oldCover) { $oldPath = $filesDir . '/' . basename($oldCover); if (file_exists($oldPath)) { $hash = substr(hash_file('sha256', $oldPath), 0, 16); $size = filesize($oldPath); $oldExt = strtolower(pathinfo($oldCover, PATHINFO_EXTENSION)); rename($oldPath, $filesDir . '/' . "{$hash}-{$size}.{$oldExt}"); } } $filename = 'cover.' . $ext; } else { $hash = substr(hash_file('sha256', $tmp), 0, 16); $size = strlen($body); $filename = "{$hash}-{$size}.{$ext}"; } } else { // Non-image : nom extrait de l'URL, sanitisé, dédupliqué $urlPath = parse_url($url, PHP_URL_PATH) ?? ''; $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($urlPath)) ?: 'file'; $i = 1; $info = pathinfo($filename); while (file_exists($filesDir . '/' . $filename)) { $filename = $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : ''); $i++; } } rename($tmp, $filesDir . '/' . $filename); if ($author !== '' || $sourceUrl !== '' || $title !== '' || !empty($extraMeta)) { $this->addFileMeta($uuid, $filename, $author, $sourceUrl, $title, $extraMeta, skipGit: true); } if ($isCover && $isImage) { $raw = file_get_contents($this->dataDir . '/' . $uuid . '/meta.json'); if ($raw !== false) { $meta = json_decode($raw, true); if (is_array($meta)) { $meta['cover'] = $filename; $this->writeMeta($this->dataDir . '/' . $uuid, $meta); } } } $this->git?->commit("add-file: {$uuid}/{$filename}"); return $filename; } public function addExternalLink(string $uuid, string $url, string $title = '', string $author = '', array $extraMeta = []): bool { if (!$this->isValidUuid($uuid) || !filter_var($url, FILTER_VALIDATE_URL)) { return false; } $dir = $this->dataDir . '/' . $uuid; $raw = file_get_contents($dir . '/meta.json'); if ($raw === false) { return false; } $meta = json_decode($raw, true); if (!is_array($meta)) { return false; } if (!isset($meta['external_links']) || !is_array($meta['external_links'])) { $meta['external_links'] = []; } foreach ($meta['external_links'] as $link) { if ($link['url'] === $url) { return true; // already exists } } $urlPath = parse_url($url, PHP_URL_PATH) ?? ''; $name = $title !== '' ? $title : (rawurldecode(basename($urlPath)) ?: $url); $entry = ['url' => $url, 'name' => $name, 'added_at' => date('Y-m-d H:i:s')]; $resolvedAuthor = $author !== '' ? $author : ($extraMeta['author'] ?? ''); if ($resolvedAuthor !== '') { $entry['author'] = $resolvedAuthor; } if (!empty($extraMeta)) { $clean = $extraMeta; unset($clean['title'], $clean['author'], $clean['credit']); $entry['meta'] = $clean; } $meta['external_links'][] = $entry; $this->writeMeta($dir, $meta); $this->rebuildBacklinksCache(); $this->git?->commit("link: {$uuid}"); return true; } public function updateExternalLinkMeta(string $uuid, string $url, array $metaUpdates): bool { if (!$this->isValidUuid($uuid)) { return false; } $dir = $this->dataDir . '/' . $uuid; $raw = file_get_contents($dir . '/meta.json'); if ($raw === false) { return false; } $meta = json_decode($raw, true); if (!is_array($meta)) { return false; } $found = false; foreach ($meta['external_links'] as &$link) { if ($link['url'] === $url) { $link['meta'] = array_merge($link['meta'] ?? [], $metaUpdates); $found = true; break; } } unset($link); if (!$found) { return false; } $this->writeMeta($dir, $meta); $this->git?->commit("link-meta: {$uuid}"); return true; } public function removeExternalLink(string $uuid, string $url): bool { if (!$this->isValidUuid($uuid)) { return false; } $dir = $this->dataDir . '/' . $uuid; $raw = file_get_contents($dir . '/meta.json'); if ($raw === false) { return false; } $meta = json_decode($raw, true); if (!is_array($meta)) { return false; } $meta['external_links'] = array_values(array_filter( $meta['external_links'] ?? [], static fn ($l) => $l['url'] !== $url )); $this->writeMeta($dir, $meta); $this->rebuildBacklinksCache(); $this->git?->commit("unlink: {$uuid}"); return true; } public function getCategories(): array { $cats = []; $source = $this->getSearchIndex() ?? $this->getAll(); foreach ($source as $article) { $cat = trim($article['category'] ?? ''); if ($cat !== '') { $cats[$cat] = ($cats[$cat] ?? 0) + 1; } } ksort($cats); return $cats; } public function renameCategory(string $old, string $new, bool $skipGit = false): void { if (!is_dir($this->dataDir)) { return; } foreach (scandir($this->dataDir) as $entry) { if ($entry === '.' || $entry === '..') { continue; } $metaPath = $this->dataDir . '/' . $entry . '/meta.json'; if (!file_exists($metaPath)) { continue; } $raw = file_get_contents($metaPath); if ($raw === false) { continue; } $meta = json_decode($raw, true); if (!is_array($meta) || trim($meta['category'] ?? '') !== $old) { continue; } $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, '', skipGit: true); $this->git?->commit("delete-cat: $name"); } public function getPrivateCategories(): array { $path = $this->dataDir . '/private_cats.json'; if (!file_exists($path)) { return []; } $data = json_decode((string)file_get_contents($path), true); return is_array($data) ? $data : []; } public function togglePrivateCategory(string $cat): void { $cats = $this->getPrivateCategories(); if (in_array($cat, $cats, true)) { $cats = array_values(array_filter($cats, fn ($c) => $c !== $cat)); } else { $cats[] = $cat; } file_put_contents( $this->dataDir . '/private_cats.json', json_encode(array_values($cats), JSON_UNESCAPED_UNICODE) ); $this->git?->commit("private-cat: $cat"); } // ─── Tag types ────────────────────────────────────────────────────────────── private function tagTypesPath(): string { return $this->dataDir . '/tag_types.json'; } public function getTagTypes(): array { $p = $this->tagTypesPath(); if (!file_exists($p)) { return []; } $data = json_decode((string) file_get_contents($p), true); return is_array($data) ? $data : []; } public function saveTagTypes(array $types): void { file_put_contents( $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). */ public function setTags(string $uuid, array $tags): void { $dir = $this->dataDir . '/' . $uuid; $metaPath = $dir . '/meta.json'; if (!file_exists($metaPath)) { return; } $meta = json_decode((string) file_get_contents($metaPath), true); if (!is_array($meta)) { return; } $meta['tags'] = $this->normalizeTags($tags); $this->writeMeta($dir, $meta); $this->rebuildSearchIndex(); $this->git?->commit("tags: " . ($meta['title'] ?? $uuid)); } /** @return list Toutes les valeurs distinctes d'un type de tag, triées. */ public function getAllTagValues(string $type): array { $values = []; foreach ($this->getSearchIndex() ?? $this->getAll() as $a) { foreach (($a['tags'][$type] ?? []) as $v) { $values[$v] = true; } } ksort($values); return array_keys($values); } private function normalizeTags(array $raw): array { $out = []; foreach ($raw as $type => $values) { $type = trim((string)$type); if ($type === '') { continue; } $vals = array_values(array_filter(array_map('trim', (array)$values), fn ($v) => $v !== '')); if ($vals !== []) { $out[$type] = $vals; } } return $out; } public function setFeatured(string $uuid, bool $featured): void { if (!$this->isValidUuid($uuid)) { return; } $dir = $this->dataDir . '/' . $uuid; $raw = @file_get_contents($dir . '/meta.json'); if ($raw === false) { return; } $meta = json_decode($raw, true); if (!is_array($meta)) { return; } $meta['featured'] = $featured; $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): bool { if (!$this->isValidUuid($uuid)) { return false; } $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)); @unlink($this->slugIndexPath()); $this->removeDir($dir); } if (is_dir($dir)) { return false; } $this->rebuildSearchIndex(); $this->rebuildBacklinksCache(); $this->git?->commit("delete: " . ($title ?? $uuid)); return true; } // ------------------------------------------------------------------ // // Cache des rétroliens // ------------------------------------------------------------------ // private function backlinksPath(): string { return $this->dataDir . '/_cache/backlinks.json'; } private function articleCachePath(string $uuid): string { return $this->dataDir . '/_cache/articles/' . $uuid . '.json'; } private function slugIndexPath(): string { return $this->dataDir . '/_cache/slug_index.json'; } private function buildSlugIndex(): void { $cacheDir = $this->dataDir . '/_cache'; if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } $index = []; // Préférer le search_index (lecture unique) plutôt que loadAll() (N lectures) $source = $this->getSearchIndex() ?? $this->loadAll(); foreach ($source as $article) { $slug = $article['slug'] ?? ''; $uuid = $article['uuid'] ?? ''; if ($slug !== '' && $uuid !== '') { $index[$slug] = $uuid; } } file_put_contents( $this->slugIndexPath(), json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); } /** * Reconstruit le cache des rétroliens. * Produit un index slug → [article minimal, ...] pour tous les articles publiés * qui pointent vers un autre article interne via leurs external_links. */ public function rebuildBacklinksCache(): void { $cacheDir = $this->dataDir . '/_cache'; if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } $index = []; foreach ($this->getAll(publishedOnly: true) as $article) { foreach ($article['external_links'] ?? [] as $link) { $path = rtrim(parse_url($link['url'] ?? '', PHP_URL_PATH) ?? '', '/'); if (!preg_match('#^/post/([a-z0-9][a-z0-9-]*)$#', $path, $m)) { continue; } $target = $m[1]; $index[$target][] = [ 'uuid' => $article['uuid'], 'slug' => $article['slug'] ?? '', 'title' => $article['title'] ?? '', 'cover' => $article['cover'] ?? '', 'category' => $article['category'] ?? '', 'published_at' => $article['published_at'] ?? '', 'created_at' => $article['created_at'] ?? '', ]; } } file_put_contents( $this->backlinksPath(), json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); } /** * Retourne les articles qui pointent vers /post/, depuis le cache. * Reconstruit le cache si absent. */ public function getBacklinks(string $slug, string $excludeUuid = ''): array { $path = $this->backlinksPath(); if (!file_exists($path)) { $this->rebuildBacklinksCache(); } $index = json_decode((string) file_get_contents($path), true); if (!is_array($index)) { return []; } $result = $index[$slug] ?? []; if ($excludeUuid !== '') { $result = array_values(array_filter($result, static fn ($a) => $a['uuid'] !== $excludeUuid)); } return $result; } // ------------------------------------------------------------------ // // Index de recherche (fichier plat) // ------------------------------------------------------------------ // /** * Reconstruit search_index.json à partir de tous les articles. * Appelé automatiquement après chaque create/update/delete. */ public function rebuildSearchIndex(): void { $index = []; foreach ($this->getAll() as $article) { $index[] = [ 'uuid' => $article['uuid'], 'slug' => $article['slug'] ?? '', 'title' => $article['title'] ?? '', 'category' => $article['category'] ?? '', 'author' => $article['author'] ?? '', 'cover' => $article['cover'] ?? '', 'published' => $article['published'], 'published_at' => $article['published_at'] ?? '', 'created_at' => $article['created_at'] ?? '', 'updated_at' => $article['updated_at'] ?? '', 'tags' => $article['tags'] ?? [], 'plain' => $this->stripForIndex($article['content'] ?? ''), ]; } file_put_contents( $this->dataDir . '/search_index.json', json_encode($index, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); $this->searchIndexCache = $index; $this->clearSearchResultsCache(); } /** Vide le cache des résultats de recherche (appelé après chaque modification). */ public function clearSearchResultsCache(): void { $dir = $this->dataDir . '/_cache/search'; if (!is_dir($dir)) { return; } foreach (scandir($dir) as $f) { if (str_ends_with($f, '.json')) { @unlink($dir . '/' . $f); } } } /** * Retourne les résultats mis en cache pour une requête anonyme, ou null si absent. * @return array|null */ public function getSearchCache(string $query): ?array { $path = $this->searchCachePath($query); if (!file_exists($path)) { return null; } $data = json_decode((string) file_get_contents($path), true); return is_array($data) ? $data : null; } /** Persiste les résultats d'une requête anonyme dans le cache. */ public function setSearchCache(string $query, array $results): void { $dir = $this->dataDir . '/_cache/search'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents( $this->searchCachePath($query), json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ); } private function searchCachePath(string $query): string { return $this->dataDir . '/_cache/search/' . md5(mb_strtolower(trim($query))) . '.json'; } /** Retourne l'index pré-construit, ou null s'il n'existe pas encore. */ public function getSearchIndex(): ?array { if ($this->searchIndexCache !== null) { return $this->searchIndexCache; } $path = $this->dataDir . '/search_index.json'; if (!file_exists($path)) { return null; } $data = json_decode((string) file_get_contents($path), true); if (!is_array($data) || empty($data)) { return null; } // Rebuild automatique si le format est obsolète (champs cover/created_at absents) if (!array_key_exists('cover', $data[0])) { $this->rebuildSearchIndex(); return $this->searchIndexCache; } // Rebuild si des UUID ont été supprimés hors CMS (ex. rsync, suppression manuelle) foreach ($data as $entry) { if (!is_dir($this->dataDir . '/' . ($entry['uuid'] ?? ''))) { $this->rebuildSearchIndex(); return $this->searchIndexCache; } } $this->searchIndexCache = $data; return $this->searchIndexCache; } /** Retire la syntaxe Markdown pour stocker du texte brut dans l'index. */ private function stripForIndex(string $md): string { $t = preg_replace('/!\[[^\]]*\]\([^)]+\)/', '', $md) ?? $md; $t = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $t) ?? $t; $t = preg_replace('/```[\s\S]*?```/', '', $t) ?? $t; $t = preg_replace('/`[^`]+`/', '', $t) ?? $t; $t = preg_replace('/^#{1,6}\s*/m', '', $t) ?? $t; $t = preg_replace('/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/', '$1', $t) ?? $t; $t = preg_replace('/^\s*[-*+|>]\s*/m', '', $t) ?? $t; $t = preg_replace('/\n{2,}/', ' ', $t) ?? $t; return trim($t); } // ------------------------------------------------------------------ // // Fichiers associés // ------------------------------------------------------------------ // public function getFiles(string $uuid): array { $dir = $this->dataDir . '/' . $uuid . '/files'; if (!is_dir($dir)) { return []; } $files = []; foreach (scandir($dir) as $name) { if ($name === '.' || $name === '..') { continue; } $path = $dir . '/' . $name; if (!is_file($path)) { continue; } $mime = mime_content_type($path) ?: 'application/octet-stream'; $files[] = [ 'name' => $name, 'size' => filesize($path), 'mime' => $mime, 'is_image' => str_starts_with($mime, 'image/'), 'is_video' => str_starts_with($mime, 'video/'), 'is_audio' => str_starts_with($mime, 'audio/'), 'uploaded_at' => date('Y-m-d H:i:s', (int)filemtime($path)), ]; } return $files; } public function deleteFile(string $uuid, string $name): bool { if (!$this->isValidUuid($uuid)) { return false; } $name = basename($name); if ($name === '' || $name[0] === '.') { return false; } $path = $this->dataDir . '/' . $uuid . '/files/' . $name; if (!is_file($path)) { return false; } return unlink($path); } public function addFile(string $uuid, array $uploadedFile): ?string { if (!$this->isValidUuid($uuid)) { return null; } $dir = $this->dataDir . '/' . $uuid . '/files'; if (!is_dir($dir)) { $this->mkArticleDir($dir); } $mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream'; if (str_starts_with($mime, 'image/')) { // HEIC/HEIF : converti en JPEG (non supporté nativement par les navigateurs) if (in_array($mime, ['image/heic', 'image/heif'], true) && extension_loaded('imagick')) { try { $img = new \Imagick($uploadedFile['tmp_name']); $img->setImageFormat('jpeg'); $img->setImageCompressionQuality(88); $converted = tempnam(sys_get_temp_dir(), 'vl_heic_') . '.jpg'; $img->writeImage($converted); $img->destroy(); $uploadedFile['tmp_name'] = $converted; $mime = 'image/jpeg'; } catch (\Exception $e) { // Échec conversion → stocke tel quel } } $ext = $this->extFromMime($mime) ?? strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION)) ?: 'jpg'; $hash = substr(hash_file('sha256', $uploadedFile['tmp_name']), 0, 16); $size = filesize($uploadedFile['tmp_name']); $name = "{$hash}-{$size}.{$ext}"; $dest = $dir . '/' . $name; if (!rename($uploadedFile['tmp_name'], $dest) && !move_uploaded_file($uploadedFile['tmp_name'], $dest)) { return null; } return $name; } // Non-image : nom sanitisé + déduplication $name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($uploadedFile['name'])); $dest = $dir . '/' . $name; $i = 1; $info = pathinfo($name); while (file_exists($dest)) { $dest = $dir . '/' . $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : ''); $i++; } if (!move_uploaded_file($uploadedFile['tmp_name'], $dest)) { return null; } return basename($dest); } // ------------------------------------------------------------------ // // Rendu // ------------------------------------------------------------------ // public function resolveFileUrls(string $uuid, string $markdown): string { $base = '/file?uuid=' . rawurlencode($uuid) . '&name='; return preg_replace_callback( '/(!?\[([^\]]*)\])\((?!https?:\/\/)(?!\/)([^)]+)\)/', static function (array $m) use ($base): string { return $m[1] . '(' . $base . rawurlencode($m[3]) . ')'; }, $markdown ) ?? $markdown; } // ------------------------------------------------------------------ // // Helpers privés // ------------------------------------------------------------------ // private function extFromMime(string $mime): ?string { return match($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif', 'image/avif' => 'avif', 'image/svg+xml' => 'svg', 'image/heic' => 'jpg', 'image/heif' => 'jpg', default => null, }; } private function loadArticle(string $dir): ?array { $metaPath = $dir . '/meta.json'; if (!file_exists($metaPath)) { return null; } $uuid = basename($dir); $cachePath = $this->articleCachePath($uuid); // Utiliser le cache si plus récent que meta.json ET index.md $contentMtime = file_exists($dir . '/index.md') ? filemtime($dir . '/index.md') : 0; if (file_exists($cachePath) && filemtime($cachePath) >= filemtime($metaPath) && filemtime($cachePath) >= $contentMtime) { $cached = json_decode((string) file_get_contents($cachePath), true); if (is_array($cached) && !empty($cached['uuid'])) { return $cached; } } $raw = file_get_contents($metaPath); if ($raw === false) { return null; } $meta = json_decode($raw, true); if (!is_array($meta) || empty($meta['uuid'])) { return null; } $contentPath = $dir . '/index.md'; $meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : ''; $meta['published'] = (bool)($meta['published'] ?? false); $meta['featured'] = (bool)($meta['featured'] ?? false); $meta['files_meta'] = $meta['files_meta'] ?? []; $meta['external_links'] = $meta['external_links'] ?? []; $meta['tags'] = $meta['tags'] ?? []; if (!empty($meta['cover'])) { $coverPath = $dir . '/files/' . basename((string)$meta['cover']); if (!file_exists($coverPath)) { $meta['cover'] = ''; } } // Écrire le cache $cacheDir = dirname($cachePath); if (!is_dir($cacheDir)) { mkdir($cacheDir, 0755, true); } file_put_contents($cachePath, json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return $meta; } public function deleteRevision(string $uuid, int $revN): void { if (!$this->isValidUuid($uuid) || $revN < 1) { return; } $dir = $this->dataDir . '/' . $uuid; $raw = @file_get_contents($dir . '/meta.json'); $meta = $raw !== false ? json_decode($raw, true) : null; if (!is_array($meta)) { return; } $revisions = $meta['revisions'] ?? []; $newRevisions = []; foreach ($revisions as $rev) { if ((int)($rev['n'] ?? 0) === $revN) { @unlink(sprintf('%s/revisions/%04d.md', $dir, $revN)); } else { $newRevisions[] = $rev; } } $meta['revisions'] = array_values($newRevisions); $this->writeMeta($dir, $meta); } public function deleteAllRevisions(string $uuid): void { if (!$this->isValidUuid($uuid)) { return; } $dir = $this->dataDir . '/' . $uuid; $revDir = $dir . '/revisions'; if (is_dir($revDir)) { foreach (glob($revDir . '/*.md') ?: [] as $f) { @unlink($f); } } $raw = @file_get_contents($dir . '/meta.json'); $meta = $raw !== false ? json_decode($raw, true) : null; if (is_array($meta)) { $meta['revisions'] = []; $this->writeMeta($dir, $meta); } } private function writeMeta(string $dir, array $meta): void { $this->allCache = null; $this->searchIndexCache = null; $uuid = $meta['uuid'] ?? basename($dir); // Invalider le cache article et le slug index @unlink($this->articleCachePath($uuid)); @unlink($this->slugIndexPath()); file_put_contents( $dir . '/meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n" ); } private function generateSlug(string $title): string { $map = [ 'à' => 'a', 'â' => 'a', 'ä' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', 'î' => 'i', 'ï' => 'i', 'ô' => 'o', 'ö' => 'o', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', 'ç' => 'c', 'æ' => 'ae', 'œ' => 'oe', ]; $slug = mb_strtolower($title, 'UTF-8'); $slug = strtr($slug, $map); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); return trim((string)$slug, '-') ?: 'article'; } private function sanitizeSlug(string $slug): string { $slug = mb_strtolower(trim($slug), 'UTF-8'); $slug = preg_replace('/[^a-z0-9-]/', '-', $slug); $slug = preg_replace('/-+/', '-', $slug); return trim((string)$slug, '-') ?: 'article'; } private function uniqueSlug(string $slug, string $excludeUuid): string { $taken = array_column( array_filter($this->getAll(), static fn ($a) => $a['uuid'] !== $excludeUuid), 'slug' ); if (!in_array($slug, $taken, true)) { return $slug; } $i = 2; while (in_array($slug . '-' . $i, $taken, true)) { $i++; } return $slug . '-' . $i; } private function generateUuid(): string { $bytes = random_bytes(16); $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); } private function isValidUuid(string $uuid): bool { return (bool)preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid ); } /** * Retourne les articles publiés qui contiennent un lien vers /post/. */ private function removeDir(string $dir): void { foreach (@scandir($dir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $dir . '/' . $entry; is_dir($path) ? $this->removeDir($path) : @unlink($path); } @rmdir($dir); } private function mkArticleDir(string $path): void { mkdir($path, 0777, true); chmod($path, 0775); } }