Les chapitres partageant le même pagesDirectory non-null et le même volume non-null (import CBZ en bloc) sont fusionnés en un seul item isVolumeGroup=true côté Application, avec volumeChaptersRange et volumeChapterCount. Le frontend affiche "Vol. X — Chapitres Y-Z" à la place de N lignes identiques.
444 lines
17 KiB
PHP
444 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Domain\Manga\Infrastructure\Persistence;
|
|
|
|
use App\Domain\Manga\Application\Query\MonitoringCriteria;
|
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
|
use App\Domain\Manga\Domain\Model\Manga as DomainManga;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\MonitoringStatus;
|
|
use App\Entity\Manga as EntityManga;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use App\Domain\Manga\Domain\Model\Chapter;
|
|
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
|
use App\Entity\Chapter as EntityChapter;
|
|
use DateTime;
|
|
|
|
readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $entityManager
|
|
) {
|
|
}
|
|
|
|
public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array
|
|
{
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
|
->select('m')
|
|
->from(EntityManga::class, 'm')
|
|
->orderBy("m.$sortBy", $sortOrder)
|
|
->setFirstResult($offset)
|
|
->setMaxResults($limit);
|
|
|
|
return array_map(
|
|
fn (EntityManga $entity) => $this->toDomain($entity),
|
|
$queryBuilder->getQuery()->getResult()
|
|
);
|
|
}
|
|
|
|
public function count(): int
|
|
{
|
|
return $this->entityManager->createQueryBuilder()
|
|
->select('COUNT(m.id)')
|
|
->from(EntityManga::class, 'm')
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
|
|
public function findById(string $id): ?DomainManga
|
|
{
|
|
// Convertir le string ID en integer pour la base de données
|
|
if (!is_numeric($id)) {
|
|
return null;
|
|
}
|
|
|
|
$entity = $this->entityManager->find(EntityManga::class, (int) $id);
|
|
|
|
return $entity ? $this->toDomain($entity) : null;
|
|
}
|
|
|
|
public function findBySlug(MangaSlug $slug): ?DomainManga
|
|
{
|
|
$entity = $this->entityManager->getRepository(EntityManga::class)
|
|
->findOneBy(['slug' => $slug->getValue()]);
|
|
|
|
return $entity ? $this->toDomain($entity) : null;
|
|
}
|
|
|
|
public function save(DomainManga $manga): void
|
|
{
|
|
// Check if this is an update (manga has a numeric ID) or a new creation
|
|
$entity = null;
|
|
if ($manga->getId() && $manga->getId()->getValue() && is_numeric($manga->getId()->getValue())) {
|
|
$entity = $this->entityManager->find(EntityManga::class, (int) $manga->getId()->getValue());
|
|
}
|
|
|
|
if (!$entity) {
|
|
$entity = new EntityManga();
|
|
}
|
|
|
|
$imageUrls = $manga->getImageUrls();
|
|
$fullImageUrl = $imageUrls?->getFull();
|
|
$thumbnailUrl = $imageUrls?->getThumbnail();
|
|
|
|
$entity->setTitle($manga->getTitle()->getValue())
|
|
->setSlug($manga->getSlug()->getValue())
|
|
->setDescription($manga->getDescription())
|
|
->setAuthor($manga->getAuthor())
|
|
->setPublicationYear($manga->getPublicationYear())
|
|
->setGenres($manga->getGenres())
|
|
->setStatus($manga->getStatus())
|
|
->setImageUrl($fullImageUrl ?? null)
|
|
->setThumbnailUrl($thumbnailUrl ?? null)
|
|
->setAlternativeSlugs($manga->getAlternativeSlugs())
|
|
->setMonitored($manga->isMonitoringEnabled())
|
|
->setLastMonitoringCheck($manga->getLastMonitoringCheck());
|
|
|
|
// Only set externalId if it exists (to avoid setting null on update)
|
|
if ($manga->getExternalId()) {
|
|
$entity->setExternalId($manga->getExternalId()->getValue());
|
|
}
|
|
|
|
if ($manga->getRating() !== null) {
|
|
$entity->setRating($manga->getRating());
|
|
}
|
|
|
|
$this->entityManager->persist($entity);
|
|
$this->entityManager->flush();
|
|
|
|
// Met à jour l'ID du modèle du domaine avec l'ID généré par la BDD
|
|
if ($entity->getId()) {
|
|
$manga->updateId(new MangaId((string) $entity->getId()));
|
|
}
|
|
|
|
// Handle new chapters added through the aggregate
|
|
foreach ($manga->pullNewChapters() as $chapter) {
|
|
$mangaEntity = $this->entityManager->find(EntityManga::class, (int) $manga->getId()->getValue());
|
|
$chapterEntity = new EntityChapter();
|
|
$chapterEntity->setManga($mangaEntity)
|
|
->setNumber($chapter->getNumber())
|
|
->setTitle($chapter->getTitle())
|
|
->setVolume($chapter->getVolume())
|
|
->setVisible($chapter->isVisible())
|
|
->setPagesDirectory($chapter->getPagesDirectory())
|
|
->setPageCount($chapter->getPageCount());
|
|
$this->entityManager->persist($chapterEntity);
|
|
}
|
|
|
|
// Handle chapters modified through the aggregate
|
|
foreach ($manga->pullModifiedChapters() as $chapter) {
|
|
$chapterEntity = $this->entityManager->find(EntityChapter::class, $chapter->getId());
|
|
if ($chapterEntity) {
|
|
$chapterEntity->setVisible($chapter->isVisible())
|
|
->setPagesDirectory($chapter->getPagesDirectory())
|
|
->setPageCount($chapter->getPageCount())
|
|
->setTitle($chapter->getTitle())
|
|
->setVolume($chapter->getVolume())
|
|
->setCbzPath($chapter->getPagesDirectory());
|
|
$this->entityManager->persist($chapterEntity);
|
|
}
|
|
}
|
|
|
|
// Handle chapters deleted through the aggregate
|
|
foreach ($manga->pullChaptersToDelete() as $chapter) {
|
|
$chapterEntity = $this->entityManager->find(EntityChapter::class, $chapter->getId());
|
|
if ($chapterEntity) {
|
|
$this->entityManager->remove($chapterEntity);
|
|
}
|
|
}
|
|
|
|
$this->entityManager->flush();
|
|
}
|
|
|
|
public function delete(DomainManga $manga): void
|
|
{
|
|
$entity = $this->entityManager->find(EntityManga::class, $manga->getId()->getValue());
|
|
|
|
if ($entity) {
|
|
$this->entityManager->remove($entity);
|
|
$this->entityManager->flush();
|
|
}
|
|
}
|
|
|
|
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array
|
|
{
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->orderBy('c.number', $sortOrder)
|
|
->setParameter('mangaId', $mangaId)
|
|
->setFirstResult($offset)
|
|
->setMaxResults($limit);
|
|
|
|
return array_map(
|
|
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
|
|
$queryBuilder->getQuery()->getResult()
|
|
);
|
|
}
|
|
|
|
public function findAllChapters(string $mangaId, string $sortOrder = 'desc'): array
|
|
{
|
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->orderBy('c.number', $sortOrder)
|
|
->setParameter('mangaId', $mangaId);
|
|
|
|
return array_map(
|
|
fn (EntityChapter $entity) => $this->toChapterDomain($entity),
|
|
$queryBuilder->getQuery()->getResult()
|
|
);
|
|
}
|
|
|
|
public function countChapters(string $mangaId): int
|
|
{
|
|
return $this->entityManager->createQueryBuilder()
|
|
->select('COUNT(c.id)')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->setParameter('mangaId', $mangaId)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
|
|
public function countAvailableChapters(string $mangaId): int
|
|
{
|
|
return $this->entityManager->createQueryBuilder()
|
|
->select('COUNT(c.id)')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
|
->setParameter('mangaId', $mangaId)
|
|
->getQuery()
|
|
->getSingleScalarResult();
|
|
}
|
|
|
|
public function findByExternalId(ExternalId $externalId): ?DomainManga
|
|
{
|
|
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
|
|
'externalId' => $externalId->getValue()
|
|
]);
|
|
|
|
return $entity ? $this->toDomain($entity) : null;
|
|
}
|
|
|
|
public function findChapterById(string $id): ?Chapter
|
|
{
|
|
$entity = $this->entityManager->find(EntityChapter::class, $id);
|
|
|
|
return $entity ? $this->toChapterDomain($entity) : null;
|
|
}
|
|
|
|
public function findVisibleChapterById(string $id): ?Chapter
|
|
{
|
|
$entity = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.id = :id')
|
|
->andWhere('c.visible = :visible')
|
|
->setParameter('id', $id)
|
|
->setParameter('visible', 1)
|
|
->getQuery()
|
|
->getOneOrNullResult();
|
|
|
|
return $entity ? $this->toChapterDomain($entity) : null;
|
|
}
|
|
|
|
public function findChapterByMangaIdAndNumber(string $mangaId, float $chapterNumber): ?Chapter
|
|
{
|
|
$entity = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->andWhere('c.number = :chapterNumber')
|
|
->setParameter('mangaId', $mangaId)
|
|
->setParameter('chapterNumber', $chapterNumber)
|
|
->getQuery()
|
|
->getOneOrNullResult();
|
|
|
|
return $entity ? $this->toChapterDomain($entity) : null;
|
|
}
|
|
|
|
public function findChaptersByMangaIdAndVolume(string $mangaId, int $volume): array
|
|
{
|
|
$entities = $this->entityManager->getRepository(EntityChapter::class)
|
|
->findBy(['manga' => $mangaId, 'volume' => $volume]);
|
|
|
|
return array_map([$this, 'toChapterDomain'], $entities);
|
|
}
|
|
|
|
public function findVisibleChaptersByMangaIdAndVolume(string $mangaId, int $volume): array
|
|
{
|
|
$entities = $this->entityManager->getRepository(EntityChapter::class)
|
|
->findBy(['manga' => $mangaId, 'volume' => $volume, 'visible' => true]);
|
|
|
|
return array_map([$this, 'toChapterDomain'], $entities);
|
|
}
|
|
|
|
public function findVisibleChaptersWithPagesByMangaIdAndVolume(string $mangaId, int $volume): array
|
|
{
|
|
$entities = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->andWhere('c.volume = :volume')
|
|
->andWhere('c.visible = true')
|
|
->andWhere('c.pagesDirectory IS NOT NULL OR c.cbzPath IS NOT NULL')
|
|
->setParameter('mangaId', $mangaId)
|
|
->setParameter('volume', $volume)
|
|
->orderBy('c.number', 'ASC')
|
|
->getQuery()
|
|
->getResult();
|
|
|
|
return array_map([$this, 'toChapterDomain'], $entities);
|
|
}
|
|
|
|
public function search(string $query, int $page = 1, int $limit = 20): array
|
|
{
|
|
$offset = ($page - 1) * $limit;
|
|
|
|
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
|
$sql = "SELECT m.* FROM manga m
|
|
WHERE m.title LIKE :query
|
|
OR m.slug LIKE :query
|
|
OR CAST(m.alternative_slugs AS TEXT) LIKE :query
|
|
ORDER BY m.title ASC
|
|
LIMIT :limit OFFSET :offset";
|
|
|
|
$rsm = new \Doctrine\ORM\Query\ResultSetMapping();
|
|
$rsm->addEntityResult(EntityManga::class, 'm');
|
|
$rsm->addFieldResult('m', 'id', 'id');
|
|
$rsm->addFieldResult('m', 'title', 'title');
|
|
$rsm->addFieldResult('m', 'slug', 'slug');
|
|
$rsm->addFieldResult('m', 'image_url', 'imageUrl');
|
|
$rsm->addFieldResult('m', 'publication_year', 'publicationYear');
|
|
$rsm->addFieldResult('m', 'description', 'description');
|
|
$rsm->addFieldResult('m', 'genres', 'genres');
|
|
$rsm->addFieldResult('m', 'created_at', 'createdAt');
|
|
$rsm->addFieldResult('m', 'rating', 'rating');
|
|
$rsm->addFieldResult('m', 'author', 'author');
|
|
$rsm->addFieldResult('m', 'external_id', 'externalId');
|
|
$rsm->addFieldResult('m', 'status', 'status');
|
|
$rsm->addFieldResult('m', 'thumbnail_url', 'thumbnailUrl');
|
|
$rsm->addFieldResult('m', 'monitored', 'monitored');
|
|
$rsm->addFieldResult('m', 'last_monitoring_check', 'lastMonitoringCheck');
|
|
$rsm->addFieldResult('m', 'alternative_slugs', 'AlternativeSlugs');
|
|
|
|
$nativeQuery = $this->entityManager->createNativeQuery($sql, $rsm);
|
|
$nativeQuery->setParameter('query', '%' . $query . '%');
|
|
$nativeQuery->setParameter('limit', $limit);
|
|
$nativeQuery->setParameter('offset', $offset);
|
|
|
|
return array_map(
|
|
fn (EntityManga $entity) => $this->toDomain($entity),
|
|
$nativeQuery->getResult()
|
|
);
|
|
}
|
|
|
|
public function countSearch(string $query): int
|
|
{
|
|
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
|
$sql = "SELECT COUNT(m.id) FROM manga m
|
|
WHERE m.title LIKE :query
|
|
OR m.slug LIKE :query
|
|
OR m.author LIKE :query
|
|
OR m.description LIKE :query
|
|
OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
|
|
|
|
$conn = $this->entityManager->getConnection();
|
|
$stmt = $conn->prepare($sql);
|
|
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
|
|
|
|
return (int) $result->fetchOne();
|
|
}
|
|
|
|
/**
|
|
* @param float[] $chapterNumbers
|
|
* @return array<float, Chapter>
|
|
*/
|
|
public function findExistingChaptersByNumbers(string $mangaId, array $chapterNumbers): array
|
|
{
|
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
|
->select('c')
|
|
->from(EntityChapter::class, 'c')
|
|
->where('c.manga = :mangaId')
|
|
->andWhere('c.number IN (:chapterNumbers)')
|
|
->setParameter('mangaId', $mangaId)
|
|
->setParameter('chapterNumbers', array_map('floatval', $chapterNumbers));
|
|
|
|
$chapters = $queryBuilder->getQuery()->getResult();
|
|
|
|
$chaptersByNumber = [];
|
|
foreach ($chapters as $chapter) {
|
|
$chaptersByNumber[(float) $chapter->getNumber()] = $this->toChapterDomain($chapter);
|
|
}
|
|
|
|
return $chaptersByNumber;
|
|
}
|
|
|
|
public function findByMonitoringCriteria(MonitoringCriteria $criteria): array
|
|
{
|
|
$queryBuilder = $this->entityManager->createQueryBuilder()
|
|
->select('m')
|
|
->from(EntityManga::class, 'm')
|
|
->where('m.monitored = :enabled')
|
|
->setParameter('enabled', $criteria->enabled);
|
|
|
|
if ($criteria->lastCheckBefore) {
|
|
$queryBuilder->andWhere('(m.lastMonitoringCheck IS NULL OR m.lastMonitoringCheck < :lastCheckBefore)')
|
|
->setParameter('lastCheckBefore', $criteria->lastCheckBefore);
|
|
}
|
|
|
|
return array_map(
|
|
fn (EntityManga $entity) => $this->toDomain($entity),
|
|
$queryBuilder->getQuery()->getResult()
|
|
);
|
|
}
|
|
|
|
private function toDomain(EntityManga $entity): DomainManga
|
|
{
|
|
return new DomainManga(
|
|
id: new MangaId((string) $entity->getId()),
|
|
title: new MangaTitle($entity->getTitle()),
|
|
slug: new MangaSlug($entity->getSlug()),
|
|
description: $entity->getDescription() ?? '',
|
|
author: $entity->getAuthor() ?? '',
|
|
publicationYear: $entity->getPublicationYear() ?? 0,
|
|
genres: $entity->getGenres() ?? [],
|
|
status: $entity->getStatus() ?? '',
|
|
externalId: $entity->getExternalId() ? new ExternalId($entity->getExternalId()) : null,
|
|
imageUrl: $entity->getImageUrl(),
|
|
rating: $entity->getRating(),
|
|
imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null,
|
|
alternativeSlugs: $entity->getAlternativeSlugs() ?? [],
|
|
createdAt: $entity->getCreatedAt(),
|
|
monitoringStatus: $entity->isMonitored() ? MonitoringStatus::enabled() : MonitoringStatus::disabled()
|
|
);
|
|
}
|
|
|
|
private function toChapterDomain(EntityChapter $entity): Chapter
|
|
{
|
|
return new Chapter(
|
|
id: new ChapterId((string) $entity->getId()),
|
|
mangaId: new MangaId((string) $entity->getManga()->getId()),
|
|
number: $entity->getNumber(),
|
|
title: $entity->getTitle(),
|
|
volume: $entity->getVolume(),
|
|
isVisible: $entity->isVisible(),
|
|
// Fallback to cbzPath during transition (Phase 4 will drop cbzPath column)
|
|
pagesDirectory: $entity->getPagesDirectory() ?? $entity->getCbzPath(),
|
|
pageCount: $entity->getPageCount(),
|
|
);
|
|
}
|
|
}
|