pagination curseur, layout 3 colonnes article, sidebar fixe
This commit is contained in:
+448
-10
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class RatingManager
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function rate(string $uuid, string $email, int $rating): void
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'INSERT INTO article_ratings (article_uuid, user_email, rating)
|
||||
VALUES (:uuid, :email, :r)
|
||||
ON CONFLICT (article_uuid, user_email)
|
||||
DO UPDATE SET rating = :r, rated_at = NOW()'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email), ':r' => $rating]);
|
||||
}
|
||||
|
||||
/** @return array{avg: float|null, count: int} */
|
||||
public function statsForArticle(string $uuid): array
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT ROUND(AVG(rating)::numeric, 1) as avg, COUNT(*) as count
|
||||
FROM article_ratings WHERE article_uuid = :uuid'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
return [
|
||||
'avg' => $row && $row['avg'] !== null ? (float)$row['avg'] : null,
|
||||
'count' => $row ? (int)$row['count'] : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function userRating(string $uuid, string $email): ?int
|
||||
{
|
||||
$st = $this->pdo->prepare(
|
||||
'SELECT rating FROM article_ratings WHERE article_uuid = :uuid AND user_email = :email'
|
||||
);
|
||||
$st->execute([':uuid' => $uuid, ':email' => strtolower($email)]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v !== false ? (int)$v : null;
|
||||
}
|
||||
}
|
||||
+172
-1
@@ -21,15 +21,186 @@ function currentUserEmail(): ?string
|
||||
return $_SESSION['user_email'] ?? null;
|
||||
}
|
||||
|
||||
function currentUserName(): string
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return '';
|
||||
}
|
||||
if (isset($_SESSION['user_display_name']) && $_SESSION['user_display_name'] !== '') {
|
||||
return $_SESSION['user_display_name'];
|
||||
}
|
||||
$name = authorDisplayName(currentUserEmail() ?? '');
|
||||
$_SESSION['user_display_name'] = $name;
|
||||
return $name;
|
||||
}
|
||||
|
||||
function authorDisplayName(string $email): string
|
||||
{
|
||||
static $cache = [];
|
||||
$key = strtolower(trim($email));
|
||||
if ($key === '') {
|
||||
return '';
|
||||
}
|
||||
if (array_key_exists($key, $cache)) {
|
||||
return $cache[$key];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if ($pdo) {
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT display_name FROM user_profiles WHERE email = :e');
|
||||
$st->execute([':e' => $key]);
|
||||
$name = $st->fetchColumn();
|
||||
$cache[$key] = ($name !== false && $name !== '') ? $name : explode('@', $key)[0];
|
||||
return $cache[$key];
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
$cache[$key] = explode('@', $key)[0];
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
function dbPdo(): ?PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
static $failed = false;
|
||||
if ($failed) {
|
||||
return null;
|
||||
}
|
||||
if ($pdo !== null) {
|
||||
return $pdo;
|
||||
}
|
||||
$dsn = $_ENV['DB_DSN'] ?? (getenv('DB_DSN') ?: '');
|
||||
$user = $_ENV['DB_USER'] ?? (getenv('DB_USER') ?: '');
|
||||
$pass = $_ENV['DB_PASS'] ?? (getenv('DB_PASS') ?: '');
|
||||
if (!$dsn) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO($dsn, $user ?: null, $pass ?: null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
} catch (\Throwable) {
|
||||
$failed = true;
|
||||
return null;
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function currentUserRoles(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_roles'])) {
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT r.name FROM roles r
|
||||
JOIN user_roles ur ON ur.role_id = r.id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_roles'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_roles'] = [];
|
||||
}
|
||||
return $_SESSION['user_roles'];
|
||||
}
|
||||
|
||||
function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, currentUserRoles(), true);
|
||||
}
|
||||
|
||||
// Capacités connues — clé => label affiché dans l'admin
|
||||
const KNOWN_CAPABILITIES = [
|
||||
'view_sources_own' => 'Voir les sources de ses propres articles',
|
||||
'view_sources_all' => 'Voir les sources de tous les articles',
|
||||
'view_drafts_own' => 'Voir ses articles non publiés',
|
||||
'view_drafts_all' => 'Voir tous les articles non publiés',
|
||||
'edit_articles_own' => 'Modifier ses propres articles',
|
||||
'edit_articles_all' => 'Modifier tous les articles',
|
||||
'rate_articles' => 'Noter des articles',
|
||||
];
|
||||
|
||||
// Groupes pour l'interface d'administration
|
||||
// 'single' => pas de variante own/all
|
||||
const CAPABILITY_GROUPS = [
|
||||
['label' => 'Sources & métadonnées', 'own' => 'view_sources_own', 'all' => 'view_sources_all'],
|
||||
['label' => 'Articles non publiés', 'own' => 'view_drafts_own', 'all' => 'view_drafts_all'],
|
||||
['label' => 'Modification', 'own' => 'edit_articles_own', 'all' => 'edit_articles_all'],
|
||||
['label' => 'Noter des articles', 'single' => 'rate_articles'],
|
||||
];
|
||||
|
||||
function currentUserCapabilities(): array
|
||||
{
|
||||
if (!isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
if (isset($_SESSION['user_capabilities'])) {
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
$pdo = dbPdo();
|
||||
if (!$pdo) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$st = $pdo->prepare(
|
||||
'SELECT DISTINCT rc.capability
|
||||
FROM role_capabilities rc
|
||||
JOIN user_roles ur ON ur.role_id = rc.role_id
|
||||
WHERE ur.user_email = :e'
|
||||
);
|
||||
$st->execute([':e' => strtolower(currentUserEmail() ?? '')]);
|
||||
$_SESSION['user_capabilities'] = $st->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
} catch (\Throwable) {
|
||||
$_SESSION['user_capabilities'] = [];
|
||||
}
|
||||
return $_SESSION['user_capabilities'];
|
||||
}
|
||||
|
||||
function hasCapability(string $cap): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
return in_array($cap, currentUserCapabilities(), true);
|
||||
}
|
||||
|
||||
function canDoOnArticle(string $baseCap, array $article): bool
|
||||
{
|
||||
if (isAdmin()) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_all')) {
|
||||
return true;
|
||||
}
|
||||
if (hasCapability($baseCap . '_own')) {
|
||||
$owner = strtolower($article['author'] ?? '');
|
||||
return $owner !== '' && $owner === strtolower(currentUserEmail() ?? '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAdmin(): bool
|
||||
{
|
||||
$email = currentUserEmail();
|
||||
if (!$email) {
|
||||
return false;
|
||||
}
|
||||
// Fallback bootstrap : var d'env
|
||||
$rawAdmin = $_ENV['ADMIN_EMAIL'] ?? (getenv('ADMIN_EMAIL') ?: '');
|
||||
$allowed = array_filter(array_map('trim', explode(',', (string)$rawAdmin)));
|
||||
return in_array(strtolower($email), array_map('strtolower', $allowed), true);
|
||||
if (in_array(strtolower($email), array_map('strtolower', $allowed), true)) {
|
||||
return true;
|
||||
}
|
||||
return hasRole('admin');
|
||||
}
|
||||
|
||||
function ssoLogoutUrl(): string
|
||||
|
||||
+115
@@ -9,3 +9,118 @@ function vd($var, ...$moreVars)
|
||||
$output = ob_get_clean();
|
||||
echo "<pre>$output</pre>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff ligne-à-ligne via LCS. Retourne un tableau de [op, line] où
|
||||
* op est '=' (inchangé), '-' (supprimé), '+' (ajouté).
|
||||
*/
|
||||
function lineDiff(string $old, string $new): array
|
||||
{
|
||||
$a = explode("\n", $old);
|
||||
$b = explode("\n", $new);
|
||||
$n = count($a);
|
||||
$m = count($b);
|
||||
|
||||
if ($n * $m > 300000) {
|
||||
return [['!', "Diff trop grand ({$n}×{$m} lignes), affichage brut."], ['-', $old], ['+', $new]];
|
||||
}
|
||||
|
||||
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
|
||||
for ($i = $n - 1; $i >= 0; $i--) {
|
||||
for ($j = $m - 1; $j >= 0; $j--) {
|
||||
$dp[$i][$j] = $a[$i] === $b[$j]
|
||||
? 1 + $dp[$i + 1][$j + 1]
|
||||
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
$diff = [];
|
||||
$i = 0;
|
||||
$j = 0;
|
||||
while ($i < $n || $j < $m) {
|
||||
if ($i < $n && $j < $m && $a[$i] === $b[$j]) {
|
||||
$diff[] = ['=', $a[$i]];
|
||||
$i++;
|
||||
$j++;
|
||||
} elseif ($j < $m && ($i >= $n || $dp[$i][$j + 1] >= $dp[$i + 1][$j])) {
|
||||
$diff[] = ['+', $b[$j++]];
|
||||
} else {
|
||||
$diff[] = ['-', $a[$i++]];
|
||||
}
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
// 16 couleurs RGB de base — distribuées sur le spectre, visuellement distinctes
|
||||
const COLOR_PALETTE_16 = [
|
||||
[220, 38, 38], // rouge
|
||||
[234, 88, 12], // orange
|
||||
[217, 119, 6], // ambre
|
||||
[161, 142, 14], // jaune-olive
|
||||
[77, 124, 15], // citron
|
||||
[22, 163, 74], // vert
|
||||
[4, 120, 87], // émeraude
|
||||
[15, 118, 110], // sarcelle
|
||||
[8, 145, 178], // cyan
|
||||
[3, 105, 161], // ciel
|
||||
[37, 99, 235], // bleu
|
||||
[79, 70, 229], // indigo
|
||||
[109, 40, 217], // violet
|
||||
[147, 51, 234], // pourpre
|
||||
[192, 38, 211], // fuchsia
|
||||
[219, 39, 119], // rose
|
||||
];
|
||||
|
||||
/**
|
||||
* Génère un dégradé CSS pour une catégorie.
|
||||
* Avec $allCats, l'assignation est séquentielle (par ordre alpha) ;
|
||||
* au-delà de 16, un décalage de teinte et d'angle différencie les palettes.
|
||||
* Sans $allCats, fallback par hachage sur la palette.
|
||||
*/
|
||||
function coverGradient(string $seed, array $allCats = []): string
|
||||
{
|
||||
$key = strtolower(trim($seed));
|
||||
|
||||
if (!empty($allCats)) {
|
||||
$keys = array_map(fn ($k) => strtolower(trim((string)$k)), array_keys($allCats));
|
||||
$pos = array_search($key, $keys, true);
|
||||
if ($pos !== false) {
|
||||
$idx = (int) $pos;
|
||||
$tier = (int) floor($idx / 16);
|
||||
$ci = $idx % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], $tier);
|
||||
}
|
||||
}
|
||||
|
||||
// Hachage déterministe en l'absence de liste
|
||||
$ci = abs(crc32($key)) % 16;
|
||||
return _paletteGradient(COLOR_PALETTE_16[$ci], 0);
|
||||
}
|
||||
|
||||
function _paletteGradient(array $rgb, int $tier): string
|
||||
{
|
||||
[$r, $g, $b] = $rgb;
|
||||
|
||||
// Tier 0 : dégradé standard clair → foncé, 135°
|
||||
// Tier 1 : plus saturé, angle inversé, 315°
|
||||
// Tier 2+ : plus sombre encore, 225°
|
||||
$tintMix = match ($tier) {
|
||||
0 => 0.65, 1 => 0.48, default => 0.35
|
||||
};
|
||||
$shadeK = match ($tier) {
|
||||
0 => 0.35, 1 => 0.25, default => 0.18
|
||||
};
|
||||
$angle = match ($tier) {
|
||||
0 => 135, 1 => 315, default => 225
|
||||
};
|
||||
|
||||
$tr = (int) round($r * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tg = (int) round($g * (1 - $tintMix) + 255 * $tintMix);
|
||||
$tb = (int) round($b * (1 - $tintMix) + 255 * $tintMix);
|
||||
|
||||
$sr = (int) round($r * $shadeK);
|
||||
$sg = (int) round($g * $shadeK);
|
||||
$sb = (int) round($b * $shadeK);
|
||||
|
||||
return "linear-gradient({$angle}deg,rgb($tr,$tg,$tb) 0%,rgb($sr,$sg,$sb) 100%)";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user