996ab3e508
- mkArticleDir() crée les répertoires avec chmod 0775 explicite (bypass umask) - delete() retourne bool et détecte l'échec sans reconstruire les index - removeDir() supprime les warnings PHP (@unlink, @rmdir, @scandir) - post_view.php affiche un message d'erreur si delete_failed=1 - index.php redirige vers l'article avec ?delete_failed=1 si échec Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1393 lines
48 KiB
PHP
1393 lines
48 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
class ArticleManager
|
|
{
|
|
private const MAX_REVISIONS = 50;
|
|
|
|
private ?array $allCache = null;
|
|
private ?array $searchIndexCache = null;
|
|
|
|
public function __construct(private string $dataDir, private ?DataGit $git = null)
|
|
{
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Lecture
|
|
// ------------------------------------------------------------------ //
|
|
|
|
public function getAll(bool $publishedOnly = false): array
|
|
{
|
|
if ($this->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<string> 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/<slug>, 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<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/<slug>.
|
|
*/
|
|
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);
|
|
}
|
|
}
|