first commit

This commit is contained in:
julien
2026-03-20 22:16:20 +01:00
commit 42a4ba3e9a
136 changed files with 10141 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
final class HtmlPostMediaReferenceExtractor implements PostMediaReferenceExtractorInterface
{
/** @return list<int> */
public function extractMediaIds(string $html): array
{
if (trim($html) === '') {
return [];
}
$document = new \DOMDocument('1.0', 'UTF-8');
$root = $this->loadFragment($document, $html);
if ($root === null) {
return [];
}
$xpath = new \DOMXPath($document);
$nodes = $xpath->query('.//*[@data-media-id]', $root);
if ($nodes === false || $nodes->length === 0) {
return [];
}
$mediaIds = [];
foreach ($nodes as $node) {
if (!$node instanceof \DOMElement) {
continue;
}
$mediaId = (int) trim($node->getAttribute('data-media-id'));
if ($mediaId > 0) {
$mediaIds[] = $mediaId;
}
}
$mediaIds = array_values(array_unique($mediaIds));
sort($mediaIds);
return $mediaIds;
}
private function loadFragment(\DOMDocument $document, string $html): ?\DOMElement
{
$previous = libxml_use_internal_errors(true);
try {
$wrapped = '<!DOCTYPE html><html><body><div data-post-media-root="1">' . $html . '</div></body></html>';
$loaded = $document->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_COMPACT);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
if ($loaded !== true) {
return null;
}
$xpath = new \DOMXPath($document);
$nodes = $xpath->query('//div[@data-post-media-root="1"]');
if ($nodes === false || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
return $node instanceof \DOMElement ? $node : null;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\ValueObject\PostMediaUsageReference;
use PDO;
/**
* Implémentation PDO des lectures et synchronisations d'usages médias portés par les articles.
*/
final class PdoPostMediaUsageRepository implements PostMediaUsageRepositoryInterface
{
public function __construct(private readonly PDO $db) {}
public function countUsages(int $mediaId): int
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM post_media WHERE media_id = :media_id');
$stmt->execute([':media_id' => $mediaId]);
return (int) $stmt->fetchColumn();
}
/**
* @param list<int> $mediaIds
* @return array<int, int>
*/
public function countUsagesByMediaIds(array $mediaIds): array
{
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return [];
}
$placeholders = $this->buildPlaceholders($mediaIds);
$stmt = $this->db->prepare(
sprintf(
'SELECT media_id, COUNT(*) AS usage_count
FROM post_media
WHERE media_id IN (%s)
GROUP BY media_id',
$placeholders,
),
);
$stmt->execute($mediaIds);
$countsByMediaId = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$countsByMediaId[(int) $row['media_id']] = (int) $row['usage_count'];
}
return $countsByMediaId;
}
/** @return list<PostMediaUsageReference> */
public function findUsages(int $mediaId, int $limit = 5): array
{
$stmt = $this->db->prepare(
'SELECT posts.id, posts.title
FROM post_media
INNER JOIN posts ON posts.id = post_media.post_id
WHERE post_media.media_id = :media_id
ORDER BY posts.id DESC
LIMIT :limit',
);
$stmt->bindValue(':media_id', $mediaId, PDO::PARAM_INT);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(
static fn (array $row): PostMediaUsageReference => new PostMediaUsageReference(
(int) $row['id'],
(string) $row['title'],
'/admin/posts/edit/' . (int) $row['id'],
),
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
}
/**
* @param list<int> $mediaIds
* @return array<int, list<PostMediaUsageReference>>
*/
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
{
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return [];
}
$limit = max(1, $limit);
$placeholders = $this->buildPlaceholders($mediaIds);
$stmt = $this->db->prepare(
sprintf(
'SELECT post_media.media_id, posts.id, posts.title
FROM post_media
INNER JOIN posts ON posts.id = post_media.post_id
WHERE post_media.media_id IN (%s)
ORDER BY post_media.media_id ASC, posts.id DESC',
$placeholders,
),
);
$stmt->execute($mediaIds);
$referencesByMediaId = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$mediaId = (int) $row['media_id'];
$referencesByMediaId[$mediaId] ??= [];
if (count($referencesByMediaId[$mediaId]) >= $limit) {
continue;
}
$referencesByMediaId[$mediaId][] = new PostMediaUsageReference(
(int) $row['id'],
(string) $row['title'],
'/admin/posts/edit/' . (int) $row['id'],
);
}
return $referencesByMediaId;
}
/**
* @param list<int> $mediaIds
*/
public function syncPostMedia(int $postId, array $mediaIds): void
{
$stmt = $this->db->prepare('DELETE FROM post_media WHERE post_id = :post_id');
$stmt->execute([':post_id' => $postId]);
$mediaIds = $this->normalizeMediaIds($mediaIds);
if ($mediaIds === []) {
return;
}
$insert = $this->db->prepare(
'INSERT OR IGNORE INTO post_media (post_id, media_id, usage_type)
VALUES (:post_id, :media_id, :usage_type)',
);
foreach ($mediaIds as $mediaId) {
$insert->execute([
':post_id' => $postId,
':media_id' => $mediaId,
':usage_type' => 'embedded',
]);
}
}
/**
* Filtre et déduplique une liste d'identifiants de médias.
*
* @param list<int> $mediaIds
* @return list<int>
*/
private function normalizeMediaIds(array $mediaIds): array
{
return array_values(array_unique(array_filter($mediaIds, static fn (int $mediaId): bool => $mediaId > 0)));
}
/**
* Construit une liste de placeholders positionnels pour une clause `IN`.
*
* @param list<int> $mediaIds
*/
private function buildPlaceholders(array $mediaIds): string
{
return implode(', ', array_fill(0, count($mediaIds), '?'));
}
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Entity\Post;
use App\Post\Domain\Repository\PostRepositoryInterface;
use PDO;
use PDOStatement;
class PdoPostRepository implements PostRepositoryInterface
{
private const SELECT = '
SELECT posts.id, posts.title, posts.content, posts.slug,
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
users.username AS author_username,
categories.name AS category_name,
categories.slug AS category_slug
FROM posts
LEFT JOIN users ON users.id = posts.author_id
LEFT JOIN categories ON categories.id = posts.category_id
';
public function __construct(private readonly PDO $db) {}
/** @return Post[] */
public function findAll(?int $categoryId = null): array
{
if ($categoryId === null) {
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
}
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
$stmt->execute([':category_id' => $categoryId]);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
{
$sql = self::SELECT;
$params = [];
if ($categoryId !== null) {
$sql .= ' WHERE posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countAll(?int $categoryId = null): int
{
if ($categoryId === null) {
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
if ($stmt === false) {
throw new \RuntimeException('Le comptage des posts a échoué.');
}
return (int) $stmt->fetchColumn();
}
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
$stmt->execute([':category_id' => $categoryId]);
return (int) $stmt->fetchColumn();
}
/** @return Post[] */
public function findRecent(int $limit): array
{
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findByUserId(int $userId, ?int $categoryId = null): array
{
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
{
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countByUserId(int $userId, ?int $categoryId = null): int
{
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
$params = [':author_id' => $userId];
if ($categoryId !== null) {
$sql .= ' AND category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function findBySlug(string $slug): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
$stmt->execute([':slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
public function findById(int $id): ?Post
{
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? Post::fromArray($row) : null;
}
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
{
$stmt = $this->db->prepare(
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)',
);
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':author_id' => $authorId,
':category_id' => $categoryId,
':created_at' => date('Y-m-d H:i:s'),
':updated_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
{
$stmt = $this->db->prepare(
'UPDATE posts
SET title = :title, content = :content, slug = :slug,
category_id = :category_id, updated_at = :updated_at
WHERE id = :id',
);
$stmt->execute([
':title' => $post->getTitle(),
':content' => $post->getContent(),
':slug' => $slug,
':category_id' => $categoryId,
':updated_at' => date('Y-m-d H:i:s'),
':id' => $id,
]);
return $stmt->rowCount();
}
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount();
}
/** @return Post[] */
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return [];
}
$params = [':q' => $normalizedQuery];
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return Post[] */
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return [];
}
$params = [':q' => $normalizedQuery];
$sql = $this->buildSearchSql($params, $categoryId, $authorId);
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
$stmt = $this->db->prepare($sql);
$this->bindParams($stmt, $params);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
{
$normalizedQuery = $this->normalizeSearchQuery($query);
if ($normalizedQuery === null) {
return 0;
}
$params = [':q' => $normalizedQuery];
$sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
if ($categoryId !== null) {
$sql .= ' AND category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($authorId !== null) {
$sql .= ' AND author_id = :author_id';
$params[':author_id'] = $authorId;
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
public function slugExists(string $slug, ?int $excludeId = null): bool
{
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
$stmt->execute([':slug' => $slug]);
$existingId = $stmt->fetchColumn();
if ($existingId === false) {
return false;
}
if ($excludeId !== null && (int) $existingId === $excludeId) {
return false;
}
return true;
}
/** @param array<string, mixed> $params */
private function buildSearchSql(array &$params, ?int $categoryId = null, ?int $authorId = null): string
{
$sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
if ($categoryId !== null) {
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
if ($authorId !== null) {
$sql .= ' AND posts.author_id = :author_id';
$params[':author_id'] = $authorId;
}
return $sql;
}
private function normalizeSearchQuery(string $query): ?string
{
preg_match_all('/[\p{L}\p{N}_]+/u', $query, $matches);
$terms = array_values(array_filter($matches[0], static fn (string $term): bool => $term !== ''));
if ($terms === []) {
return null;
}
$quotedTerms = array_map(
static fn (string $term): string => '"' . str_replace('"', '""', $term) . '"',
$terms,
);
return implode(' ', $quotedTerms);
}
/** @param array<string, mixed> $params */
private function bindParams(PDOStatement $stmt, array $params): void
{
foreach ($params as $name => $value) {
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
}
/** @param array<int, array<string, mixed>> $rows
* @return Post[] */
private function hydratePosts(array $rows): array
{
return array_map(static fn (array $row): Post => Post::fromArray($row), $rows);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
use PDO;
/**
* Vérifie si un terme de taxonomie est encore référencé par au moins un post.
*/
final readonly class PdoTaxonUsageChecker implements TaxonUsageCheckerInterface
{
public function __construct(private PDO $db) {}
public function isTaxonInUse(int $taxonId): bool
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
$stmt->execute([':category_id' => $taxonId]);
return (int) $stmt->fetchColumn() > 0;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\ValueObject\PostMediaUsageReference;
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
use Netig\Netslim\Media\Contracts\MediaUsageReference;
/**
* Adaptateur entre les usages médias exposés par le module Post et le port lu par Media.
*/
final class PostMediaUsageReader implements MediaUsageReaderInterface
{
public function __construct(
private readonly PostMediaUsageRepositoryInterface $postMediaUsageRepository,
) {}
public function countUsages(int $mediaId): int
{
return $this->postMediaUsageRepository->countUsages($mediaId);
}
/**
* @param list<int> $mediaIds
* @return array<int, int>
*/
public function countUsagesByMediaIds(array $mediaIds): array
{
return $this->postMediaUsageRepository->countUsagesByMediaIds($mediaIds);
}
/** @return list<MediaUsageReference> */
public function findUsages(int $mediaId, int $limit = 5): array
{
return $this->mapUsageReferences($this->postMediaUsageRepository->findUsages($mediaId, $limit));
}
/**
* @param list<int> $mediaIds
* @return array<int, list<MediaUsageReference>>
*/
public function findUsagesByMediaIds(array $mediaIds, int $limit = 5): array
{
$referencesByMediaId = [];
foreach ($this->postMediaUsageRepository->findUsagesByMediaIds($mediaIds, $limit) as $mediaId => $references) {
$referencesByMediaId[$mediaId] = $this->mapUsageReferences($references);
}
return $referencesByMediaId;
}
/**
* Transforme les références d'usage issues du module Post vers le VO exposé au module Media.
*
* @param list<PostMediaUsageReference> $references
* @return list<MediaUsageReference>
*/
private function mapUsageReferences(array $references): array
{
return array_map(
static fn (PostMediaUsageReference $reference): MediaUsageReference => new MediaUsageReference(
$reference->getPostId(),
$reference->getPostTitle(),
$reference->getPostEditPath(),
),
$references,
);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Post\Infrastructure;
use PDO;
/**
* Synchronise l'index FTS5 du module Post avec les articles présents en base.
*/
final class PostSearchIndexer
{
public static function syncFtsIndex(PDO $db): void
{
$db->exec("
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
COALESCE((SELECT username FROM users WHERE id = p.author_id), '')
FROM posts p
WHERE p.id NOT IN (SELECT rowid FROM posts_fts)
");
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Post\Application\PostApplicationService;
use App\Post\Application\PostServiceInterface;
use App\Post\Application\UseCase\CreatePost;
use App\Post\Application\UseCase\DeletePost;
use App\Post\Application\UseCase\UpdatePost;
use App\Post\Domain\Repository\PostMediaUsageRepositoryInterface;
use App\Post\Domain\Repository\PostRepositoryInterface;
use App\Post\Domain\Service\PostMediaReferenceExtractorInterface;
use App\Post\Domain\Service\PostSlugGenerator;
use App\Post\Infrastructure\HtmlPostMediaReferenceExtractor;
use App\Post\Infrastructure\PdoPostMediaUsageRepository;
use App\Post\Infrastructure\PdoPostRepository;
use App\Post\Infrastructure\PdoTaxonUsageChecker;
use App\Post\Infrastructure\PostMediaUsageReader;
use App\Post\UI\Http\RssController;
use function DI\autowire;
use function DI\factory;
use Netig\Netslim\Media\Contracts\MediaUsageReaderInterface;
use Netig\Netslim\Taxonomy\Contracts\TaxonUsageCheckerInterface;
return [
PostServiceInterface::class => autowire(PostApplicationService::class),
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
PostMediaUsageRepositoryInterface::class => autowire(PdoPostMediaUsageRepository::class),
PostMediaReferenceExtractorInterface::class => autowire(HtmlPostMediaReferenceExtractor::class),
TaxonUsageCheckerInterface::class => autowire(PdoTaxonUsageChecker::class),
PostSlugGenerator::class => autowire(),
CreatePost::class => autowire(),
UpdatePost::class => autowire(),
DeletePost::class => autowire(),
MediaUsageReaderInterface::class => autowire(PostMediaUsageReader::class),
RssController::class => factory(function (PostServiceInterface $postService): RssController {
return new RssController(
$postService,
rtrim($_ENV['APP_URL'] ?? 'http://localhost', '/'),
$_ENV['APP_NAME'] ?? 'Netslim Blog',
);
}),
];