pagination curseur, layout 3 colonnes article, sidebar fixe

This commit is contained in:
Cedric Abonnel
2026-05-12 00:42:51 +02:00
parent d774042be9
commit be09fad48f
91 changed files with 8152 additions and 816 deletions
+448 -10
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
class ArticleManager
{
private const MAX_REVISIONS = 50;
public function __construct(private string $dataDir)
{
}
@@ -65,11 +67,24 @@ class ArticleManager
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
public function create(string $title, string $content, bool $published, string $slug = '', string $publishedAt = '', string $author = '', string $seoTitle = '', string $seoDescription = '', string $ogImage = '', string $category = ''): string
{
$uuid = $this->generateUuid();
$slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
@@ -91,9 +106,13 @@ class ArticleManager
'created_at' => $now,
'updated_at' => $now,
'revisions' => [],
'cover' => '',
'files_meta' => [],
'external_links' => [],
'seo_title' => $seoTitle,
'seo_description' => $seoDescription,
'og_image' => $ogImage,
'category' => $category,
];
$this->writeMeta($dir, $meta);
file_put_contents($dir . '/index.md', ltrim($content));
@@ -101,18 +120,37 @@ class ArticleManager
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 = ''): 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 = ''): void
{
$article = $this->getByUuid($uuid);
if (!$article) {
return;
}
$slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
$slug = $this->uniqueSlug($slug, $uuid);
$slug = $slug !== '' ? $this->sanitizeSlug($slug) : $this->generateSlug($title);
$slug = $this->uniqueSlug($slug, $uuid);
// Snapshot de l'état courant avant écrasement
$revisions = $article['revisions'] ?? [];
if ($revisionComment !== '') {
$revisions[] = ['date' => date('Y-m-d H:i:s'), 'comment' => $revisionComment];
$revDir = $this->dataDir . '/' . $uuid . '/revisions';
if (!is_dir($revDir)) {
mkdir($revDir, 0755, true);
}
$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)));
}
$meta = [
@@ -125,15 +163,377 @@ class ArticleManager
'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,
];
$dir = $this->dataDir . '/' . $uuid;
$this->writeMeta($dir, $meta);
file_put_contents($dir . '/index.md', ltrim($content));
}
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 addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$filename = basename($filename);
$path = $this->dataDir . '/' . $uuid . '/files/' . $filename;
if (!file_exists($path)) {
return;
}
$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);
}
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);
}
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)) {
mkdir($filesDir, 0755, true);
}
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);
}
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);
}
}
}
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);
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);
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);
return true;
}
public function getCategories(): array
{
$cats = [];
foreach ($this->getAll() 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): 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);
}
}
public function deleteCategory(string $name): void
{
$this->renameCategory($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)
);
}
public function delete(string $uuid): void
{
if (!$this->isValidUuid($uuid)) {
@@ -205,6 +605,24 @@ class ArticleManager
mkdir($dir, 0755, true);
}
$mime = mime_content_type($uploadedFile['tmp_name']) ?: 'application/octet-stream';
if (str_starts_with($mime, 'image/')) {
$ext = $this->extFromMime($mime)
?? strtolower(pathinfo($uploadedFile['name'], PATHINFO_EXTENSION))
?: 'jpg';
$hash = substr(hash_file('sha256', $uploadedFile['tmp_name']), 0, 16);
$size = $uploadedFile['size'] ?? filesize($uploadedFile['tmp_name']);
$name = "{$hash}-{$size}.{$ext}";
$dest = $dir . '/' . $name;
// Même hash = même contenu : pas de collision réelle
if (!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;
@@ -213,7 +631,6 @@ class ArticleManager
$dest = $dir . '/' . $info['filename'] . '_' . $i . (isset($info['extension']) ? '.' . $info['extension'] : '');
$i++;
}
if (!move_uploaded_file($uploadedFile['tmp_name'], $dest)) {
return null;
}
@@ -221,7 +638,7 @@ class ArticleManager
}
// ------------------------------------------------------------------ //
// Rendu : résout les chemins relatifs dans le contenu Markdown
// Rendu
// ------------------------------------------------------------------ //
public function resolveFileUrls(string $uuid, string $markdown): string
@@ -241,6 +658,18 @@ class ArticleManager
// 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',
default => null,
};
}
private function loadArticle(string $dir): ?array
{
$metaPath = $dir . '/meta.json';
@@ -255,8 +684,17 @@ class ArticleManager
return null;
}
$meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
$meta['published'] = (bool)($meta['published'] ?? false);
$meta['content'] = file_exists($contentPath) ? (string)file_get_contents($contentPath) : '';
$meta['published'] = (bool)($meta['published'] ?? false);
$meta['files_meta'] = $meta['files_meta'] ?? [];
$meta['external_links'] = $meta['external_links'] ?? [];
if (!empty($meta['cover'])) {
$coverPath = $dir . '/files/' . basename((string)$meta['cover']);
if (!file_exists($coverPath)) {
$meta['cover'] = '';
}
}
return $meta;
}