From 7068bd1a34341888a19d4f76b1c8b9b4a9f381f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guillot?= Date: Wed, 24 Jul 2024 14:10:28 +0200 Subject: [PATCH] Added: - CbrToCbzConverter.php - import now convert .cbr to .cbz - import improvement, multiple files --- Dockerfile | 2 + assets/controllers/import_match_controller.js | 51 ++++ config/services.yaml | 4 + src/Controller/ConversionController.php | 65 +++++ src/Controller/ImportController.php | 263 ++++++++++-------- src/Repository/MangaRepository.php | 2 +- src/Service/CbrToCbzConverter.php | 67 +++++ src/Service/CbzService.php | 14 +- src/Service/MangaImportService.php | 78 +++--- templates/conversion/index.html.twig | 43 +++ templates/import/index.html.twig | 10 +- templates/import/match.html.twig | 164 ++++++----- templates/manga/_chapter_list.html.twig | 16 +- templates/menu/menu.html.twig | 6 + 14 files changed, 547 insertions(+), 238 deletions(-) create mode 100644 assets/controllers/import_match_controller.js create mode 100644 src/Controller/ConversionController.php create mode 100644 src/Service/CbrToCbzConverter.php create mode 100644 templates/conversion/index.html.twig diff --git a/Dockerfile b/Dockerfile index 0764b62..e3a2b8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ file \ gettext \ git \ + unrar-free \ + p7zip-full \ && rm -rf /var/lib/apt/lists/* RUN set -eux; \ diff --git a/assets/controllers/import_match_controller.js b/assets/controllers/import_match_controller.js new file mode 100644 index 0000000..e53d91d --- /dev/null +++ b/assets/controllers/import_match_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ["checkbox", "modal", "modalContent"] + + toggleAllCheckboxes(event) { + this.checkboxTargets.forEach(checkbox => { + checkbox.checked = event.target.checked; + }); + } + + updateMangaInfo(event) { + const select = event.target; + const selectedOption = select.options[select.selectedIndex]; + const mangaInfo = JSON.parse(selectedOption.dataset.mangaInfo); + } + + showDetails(event) { + const fileId = event.currentTarget.dataset.fileId; + const select = document.querySelector(`select[name="manga_slug[${fileId}]"]`); + const mangaInfo = JSON.parse(select.options[select.selectedIndex].dataset.mangaInfo); + + this.modalContentTarget.innerHTML = ` +

${mangaInfo.title}

+
+

Author: ${mangaInfo.author || 'N/A'}

+

Publication Year: ${mangaInfo.publicationYear || 'N/A'}

+

Genres: ${mangaInfo.genres ? mangaInfo.genres.join(', ') : 'N/A'}

+

Description: ${this.truncate(mangaInfo.description || 'N/A', 200)}

+
+ `; + + this.modalTarget.classList.remove('hidden'); + } + + closeModal() { + this.modalTarget.classList.add('hidden'); + } + + confirmSelected(event) { + const selectedFiles = this.checkboxTargets.filter(checkbox => checkbox.checked).map(checkbox => checkbox.value); + if (selectedFiles.length === 0) { + event.preventDefault(); + alert('Veuillez sélectionner au moins un fichier à importer.'); + } + } + + truncate(str, length) { + return str.length > length ? str.substring(0, length) + '...' : str; + } +} diff --git a/config/services.yaml b/config/services.yaml index 4b5ed1c..adce1b7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -60,6 +60,10 @@ services: App\Controller\MangaController: arguments: $projectDir: '%kernel.project_dir%' + + App\Service\CbrToCbzConverter: + arguments: + $projectDir: '%kernel.project_dir%' App\EventSubscriber\QueueStatusSubscriber: tags: diff --git a/src/Controller/ConversionController.php b/src/Controller/ConversionController.php new file mode 100644 index 0000000..3934bcb --- /dev/null +++ b/src/Controller/ConversionController.php @@ -0,0 +1,65 @@ +isMethod('POST')) { + /** @var UploadedFile $file */ + $file = $request->files->get('file'); + + if ($file && $file->getClientOriginalExtension() === 'cbr') { + $originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $tempFilePath = $file->getPathname(); + + try { + $cbzPath = $this->cbrToCbzConverter->convert($tempFilePath); + + $response = new BinaryFileResponse($cbzPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $originalFileName . '.cbz' + ); + $response->headers->set('Content-Type', 'application/x-cbz'); + $response->headers->set('Turbo-Visit-Control', 'reload'); + + $response->deleteFileAfterSend(true); + + return $response; + } catch (\Exception $e) { + $this->notificationService->sendUpdate([ + 'status' => 'error', + 'message' => 'Une erreur est survenue lors de la conversion : ' . $e->getMessage() + ]); + } + } else { + $this->notificationService->sendUpdate([ + 'status' => 'error', + 'message' => 'Veuillez sélectionner un fichier CBR valide.' + ]); + } + } + + return $this->render('conversion/index.html.twig'); + } +} diff --git a/src/Controller/ImportController.php b/src/Controller/ImportController.php index 85f7b3a..db4a6ce 100644 --- a/src/Controller/ImportController.php +++ b/src/Controller/ImportController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Repository\ChapterRepository; use App\Repository\MangaRepository; +use App\Service\CbrToCbzConverter; use App\Service\CbzService; use App\Service\MangaImportService; use App\Service\NotificationService; @@ -18,16 +19,15 @@ use Symfony\Component\String\Slugger\SluggerInterface; class ImportController extends AbstractController { - private const UPLOADS_DIRECTORY = 'public/uploads'; + private const string UPLOADS_DIRECTORY = 'public/uploads'; public function __construct( - private readonly string $projectDir, - private readonly CbzService $cbzService, - private readonly MangaImportService $mangaImportService, -// private SluggerInterface $slugger, - private NotificationService $notificationService, - private MangaRepository $mangaRepository, - private ChapterRepository $chapterRepository + private readonly string $projectDir, + private readonly CbzService $cbzService, + private readonly MangaImportService $mangaImportService, + private readonly NotificationService $notificationService, + private readonly MangaRepository $mangaRepository, + private readonly CbrToCbzConverter $cbrToCbzConverter ) { @@ -36,27 +36,44 @@ class ImportController extends AbstractController #[Route('/manga/import', name: 'app_manga_import')] public function index(Request $request, SessionInterface $session): Response { - if ($request->isMethod('post')) { - $file = $request->files->get('file'); - if ($file && $file->getClientOriginalExtension() === 'cbz') { - $originalFileName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); - $filename = uniqid() . '.' . $file->getClientOriginalExtension(); + if ($request->isMethod('POST')) { + $files = $request->files->get('files'); + if ($files) { + $importFiles = []; + foreach ($files as $file) { + if ($file && in_array($file->getClientOriginalExtension(), ['cbz', 'cbr'])) { + $originalFileName = $file->getClientOriginalName(); + $filename = uniqid() . '.' . $file->getClientOriginalExtension(); - try { - $file->move($this->projectDir . '/' . self::UPLOADS_DIRECTORY, $filename); - $session->set('import_file_path', $this->projectDir . '/' .self::UPLOADS_DIRECTORY . '/' . $filename); - $session->set('import_original_file_name', $originalFileName); + try { + $file->move($this->projectDir . '/' . self::UPLOADS_DIRECTORY, $filename); + $importFiles[] = [ + 'id' => uniqid(), + 'path' => $this->projectDir . '/' . self::UPLOADS_DIRECTORY . '/' . $filename, + 'original_name' => $originalFileName, + ]; + } catch (FileException $e) { + $this->notificationService->sendUpdate([ + 'status' => 'error', + 'message' => 'Une erreur est survenue lors de l\'import du fichier ' . $originalFileName, + ]); + } + } else { + $this->notificationService->sendUpdate([ + 'status' => 'error', + 'message' => 'Le fichier ' . $file->getClientOriginalName() . ' doit être au format CBZ ou CBR.', + ]); + } + } + + if (!empty($importFiles)) { + $session->set('import_files', $importFiles); return $this->redirectToRoute('import_match'); - } catch (FileException $e) { - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Une erreur est survenue lors de l\'import du fichier.' - ]); } } else { $this->notificationService->sendUpdate([ 'status' => 'error', - 'message' => 'Le fichier doit être au format CBZ.' + 'message' => 'Aucun fichier n\'a été sélectionné.', ]); } } @@ -70,128 +87,134 @@ class ImportController extends AbstractController #[Route('/import/match', name: 'import_match')] public function match(Request $request, SessionInterface $session): Response { - $filePath = $session->get('import_file_path'); - $originalFileName = $session->get('import_original_file_name'); - if (!$filePath || !$originalFileName) { + $files = $session->get('import_files', []); + if (empty($files)) { return $this->redirectToRoute('app_manga_import'); } - $metadata = $this->cbzService->extractMetadata($filePath, $originalFileName); - if($metadata['title'] === '' || is_null($metadata['title'])){ - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Impossible de détecter le titre du manga.' - ]); - return $this->redirectToRoute('app_manga_import'); - } + $processedFiles = []; + foreach ($files as $fileId => $fileInfo) { + $filePath = $fileInfo['path']; + $originalFileName = $fileInfo['original_name']; - $mangas = $this->mangaRepository->findBySlug($metadata['title']); - - $mangasChapters = []; - foreach ($mangas as $manga) { - if(!is_null($metadata['chapter'])){ - $chapters = $this->chapterRepository->findBy([ - 'manga' => $manga, - 'number' => $metadata['chapter'] - ]); - $chapters = [$chapters[0]->getVolume() => $chapters]; - }else{ - $chapters = $this->chapterRepository->findBy([ - 'manga' => $manga, - 'volume' => (int) $metadata['volume'] - ]); - - $chapters = [$metadata['volume'] => $chapters]; + $fileExtension = pathinfo($filePath, PATHINFO_EXTENSION); + if (strtolower($fileExtension) === 'cbr') { + $cbzPath = $this->cbrToCbzConverter->convert($filePath); + $filePath = $cbzPath; + $originalFileName = pathinfo($originalFileName, PATHINFO_FILENAME) . '.cbz'; + $files[$fileId]['path'] = $filePath; + $files[$fileId]['original_name'] = $originalFileName; } - $mangasChapters[$manga->getSlug()] = $chapters; + + $metadata = $this->cbzService->extractMetadata($filePath, $originalFileName); + $mangas = $this->mangaRepository->findBySlug($metadata['title']); + + $mangaOptions = []; + foreach ($mangas as $manga) { + $mangaOptions[] = [ + 'slug' => $manga->getSlug(), + 'title' => $manga->getTitle(), + 'author' => $manga->getAuthor(), + 'publicationYear' => $manga->getPublicationYear(), + 'genres' => $manga->getGenres(), + 'description' => $manga->getDescription() + ]; + } + + $processedFiles[] = [ + 'id' => $fileId, + 'originalFileName' => $originalFileName, + 'fileSize' => $this->formatBytes(filesize($filePath)), + 'metadata' => $metadata, + 'mangaOptions' => $mangaOptions + ]; } - if(empty($mangas)) { - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Aucun manga trouvé avec ce titre.' - ]); - return $this->redirectToRoute('app_manga_search', ['query' => $metadata['title']]); - } - - if ($request->isMethod('post')) { - $session->set('import_metadata', $request->request->all()); - return $this->redirectToRoute('import_confirm'); - } + $session->set('import_files', $files); return $this->render('import/match.html.twig', [ - 'mangas' => $mangas, - 'volume' => $metadata['volume'], - 'chapters' => $mangasChapters + 'files' => $processedFiles ]); } - #[Route('/import/confirm', name: 'import_confirm')] + private function formatBytes($bytes, $precision = 2) { + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + $bytes /= (1 << (10 * $pow)); + return round($bytes, $precision) . ' ' . $units[$pow]; + } + + #[Route('/import/confirm', name: 'import_confirm', methods: ['POST'])] public function confirm(Request $request, SessionInterface $session): Response { - if (!$request->isMethod('POST')) { - return $this->redirectToRoute('app_manga_import'); - } + $files = $session->get('import_files', []); + $selectedFiles = $request->request->all('selected'); + $mangaSlugs = $request->request->all('manga_slug'); + $volumes = $request->request->all('volume'); + $chapters = $request->request->all('chapter'); - $action = $request->request->get('action'); - $mangaSlug = $request->request->get('manga_slug'); - $volume = $request->request->get('volume'); + $importedFiles = []; + $errors = []; - if ($action === 'confirm') { - // Logique de confirmation - $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]); - if (!$manga) { - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Manga non trouvé.' - ]); - return $this->redirectToRoute('app_manga_import'); + foreach ($selectedFiles as $fileId) { + if (!isset($files[$fileId])) { + continue; } - $filePath = $session->get('import_file_path'); - if (!$filePath) { - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Fichier d\'import non trouvé.' - ]); - return $this->redirectToRoute('app_manga_import'); - } - $originalFileName = $session->get('import_original_file_name'); - - // Ici, vous pouvez ajouter la logique pour importer effectivement le fichier - // Par exemple : - // $this->mangaImportService->importVolume($manga, $volume, $filePath); + $file = $files[$fileId]; + $mangaSlug = $mangaSlugs[$fileId] ?? null; + $volume = $volumes[$fileId] ?? null; + $chapter = $chapters[$fileId] ?? null; try { - $this->mangaImportService->importVolume($manga, (int)$volume, $filePath, $originalFileName); - } catch (\Exception $e) { - $this->notificationService->sendUpdate([ - 'status' => 'error', - 'message' => 'Erreur lors de l\'import : ' . $e->getMessage() - ]); - } + $manga = $this->mangaRepository->findOneBy(['slug' => $mangaSlug]); + if (!$manga) { + throw new \Exception('Manga non trouvé.'); + } + if(!is_null($chapter)){ + $chapter = $manga->getChapterByNumber($chapter); + if (!$chapter) { + throw new \Exception('Chapitre non trouvé.'); + } + } + + $importedFiles[] = $file['original_name']; + $this->mangaImportService->importFile($manga, $volume, $chapter,$file['path']); + } catch (\Exception $e) { + $errors[] = "Erreur lors de l'import de {$file['original_name']} : " . $e->getMessage(); + } + } + + // Nettoyer les fichiers temporaires non importés + foreach ($files as $fileId => $file) { + if (!in_array($fileId, (array)$selectedFiles) && file_exists($file['path'])) { + unlink($file['path']); + } + } + + // Nettoyer la session + $session->remove('import_files'); + + // Préparer le message de notification + if (!empty($importedFiles)) { + $successMessage = 'Fichiers importés avec succès : ' . implode(', ', $importedFiles); $this->notificationService->sendUpdate([ 'status' => 'success', - 'message' => 'Import confirmé avec succès.' - ]); - - return $this->redirectToRoute('app_manga_show', ['mangaSlug' => $mangaSlug]); - } elseif ($action === 'refuse') { - // Logique de refus - $filePath = $session->get('import_file_path'); - if ($filePath && file_exists($filePath)) { - unlink($filePath); // Supprime le fichier temporaire - } - $session->remove('import_file_path'); - $session->remove('import_original_file_name'); - - $this->notificationService->sendUpdate([ - 'status' => 'info', - 'message' => 'Import refusé. Le fichier a été supprimé.' + 'message' => $successMessage ]); } - return $this->redirectToRoute('app_manga_import'); + if (!empty($errors)) { + $errorMessage = implode("\n", $errors); + $this->notificationService->sendUpdate([ + 'status' => 'error', + 'message' => $errorMessage + ]); + } + + return $this->redirectToRoute('app_manga'); } } diff --git a/src/Repository/MangaRepository.php b/src/Repository/MangaRepository.php index b4930a2..0549798 100644 --- a/src/Repository/MangaRepository.php +++ b/src/Repository/MangaRepository.php @@ -66,7 +66,7 @@ class MangaRepository extends ServiceEntityRepository $stmt = $conn->prepare($sql); $resultSet = $stmt->executeQuery([ 'slug' => $slug, - 'max_distance' => strlen($slug) / 3 + 'max_distance' => intval(ceil(strlen($slug) / 3)) ]); $results = $resultSet->fetchAllAssociative(); diff --git a/src/Service/CbrToCbzConverter.php b/src/Service/CbrToCbzConverter.php new file mode 100644 index 0000000..6667e96 --- /dev/null +++ b/src/Service/CbrToCbzConverter.php @@ -0,0 +1,67 @@ +tempDir = $projectDir . '/public/tmp'; + $this->filesystem = new Filesystem(); + } + + public function convert(string $cbrPath): string + { + $tempDir = $this->tempDir . '/' . uniqid('cbr_conversion_'); + $this->filesystem->mkdir($tempDir); + + $extractDir = $tempDir . '/extract'; + $this->filesystem->mkdir($extractDir); + + $process = new Process(['unrar-free', 'x', $cbrPath, $extractDir]); + $process->run(); + + // Si unrar échoue, essayer avec 7z + if (!$process->isSuccessful()) { + $process = new Process(['7z', 'x', $cbrPath, "-o$extractDir"]); + $process->run(); + + if (!$process->isSuccessful()) { + throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput()); + } + } + + // Créer le CBZ + $cbzFileName = pathinfo($cbrPath, PATHINFO_FILENAME) . '.cbz'; + $cbzPath = $this->tempDir . '/' . $cbzFileName; + $zip = new \ZipArchive(); + if ($zip->open($cbzPath, \ZipArchive::CREATE) !== TRUE) { + throw new \RuntimeException("Cannot create ZIP file"); + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($extractDir), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (!$file->isDir()) { + $filePath = $file->getRealPath(); + $relativePath = substr($filePath, strlen($extractDir) + 1); + $zip->addFile($filePath, $relativePath); + } + } + + $zip->close(); + + $this->filesystem->remove($tempDir); + + return $cbzPath; + } +} diff --git a/src/Service/CbzService.php b/src/Service/CbzService.php index 456e44f..1cd5f4c 100644 --- a/src/Service/CbzService.php +++ b/src/Service/CbzService.php @@ -103,7 +103,13 @@ class CbzService if (preg_match($titlePattern, $fileName, $matches)) { return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString(); } - return ''; + + $newFormatPattern = '/^(?P.*?)_\d+/'; + if (preg_match($newFormatPattern, $fileName, $matches)) { + return $this->slugger->slug(trim($matches['title']), '-')->lower()->toString(); + } + + return $this->slugger->slug(pathinfo($fileName, PATHINFO_FILENAME), '-')->lower()->toString(); } private function extractVolume(string $fileName): string @@ -121,6 +127,12 @@ class CbzService if (preg_match($chapterPattern, $fileName, $matches)) { return $matches['chapter']; } + + $newFormatPattern = '/_(?P<chapter>\d+)(?:\.\w+)?$/'; + if (preg_match($newFormatPattern, $fileName, $matches)) { + return $matches['chapter']; + } + return ''; } diff --git a/src/Service/MangaImportService.php b/src/Service/MangaImportService.php index bcf63b9..db3207d 100644 --- a/src/Service/MangaImportService.php +++ b/src/Service/MangaImportService.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\Entity\Chapter; use App\Entity\Manga; use App\Repository\ChapterRepository; use Doctrine\ORM\EntityManagerInterface; @@ -18,7 +19,6 @@ class MangaImportService private readonly string $projectDir, private readonly EntityManagerInterface $entityManager, private readonly ChapterRepository $chapterRepository, - private readonly CbzService $cbzService, private readonly Filesystem $filesystem, private readonly SluggerInterface $slugger ) @@ -28,42 +28,65 @@ class MangaImportService /** * @throws Exception */ - #[NoReturn] public function importVolume(Manga $manga, int $volume, string $tempFilePath, string $originalFileName): void + public function importFile(Manga $manga, ?int $volume, ?Chapter $chapter, string $tempFilePath): void { - // Extraire les métadonnées du fichier CBZ - $metadata = $this->cbzService->extractMetadata($tempFilePath, $originalFileName); + if ($chapter !== null) { + $this->importChapter($manga, $chapter, $tempFilePath); + } elseif ($volume !== null) { + $this->importVolume($manga, $volume, $tempFilePath); + } else { + throw new \RuntimeException("Impossible de déterminer s'il s'agit d'un volume ou d'un chapitre."); + } + } - // Créer le nom de fichier et le chemin pour le stockage permanent - $permanentFileName = $this->createPermanentFileName($manga, $volume, $metadata); + /** + * @throws Exception + */ + private function importVolume(Manga $manga, int $volume, string $tempFilePath): void + { + $permanentFileName = $this->createPermanentFileName($manga, $volume); $mangaDirectory = $this->createMangaDirectory($manga); $permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName; - // Vérifier si le fichier existe déjà if ($this->filesystem->exists($permanentFilePath)) { - throw new \RuntimeException("Un fichier pour ce volume/chapitre existe déjà."); + throw new \RuntimeException("Un fichier pour ce volume existe déjà."); } - // Déplacer le fichier vers l'emplacement permanent $this->filesystem->mkdir(dirname($permanentFilePath), 0755); $this->filesystem->rename($tempFilePath, $permanentFilePath, true); - // Mettre à jour ou créer les entités Chapter - if (isset($metadata['chapter'])) { - // Si c'est un chapitre spécifique - $this->updateChapter($manga, $volume, $metadata['chapter'], $permanentFilePath); - } else { - // Si c'est un volume entier, mettre à jour tous les chapitres du volume - $this->updateVolumeChapters($manga, $volume, $permanentFilePath); - } + $this->updateVolumeChapters($manga, $volume, $permanentFilePath); $this->entityManager->flush(); } - private function createPermanentFileName(Manga $manga, int $volume, array $metadata): string + /** + * @throws Exception + */ + private function importChapter(Manga $manga, Chapter $chapter, string $tempFilePath): void + { + $volume = $chapter->getVolume(); + $permanentFileName = $this->createPermanentFileName($manga, $volume, $chapter->getNumber()); + $mangaDirectory = $this->createMangaDirectory($manga); + $permanentFilePath = $this->projectDir . '/' . $mangaDirectory .'/volume_' . sprintf('%02d', $volume) . '/' . $permanentFileName; + + if ($this->filesystem->exists($permanentFilePath)) { + throw new \RuntimeException("Un fichier pour ce chapitre existe déjà."); + } + + $this->filesystem->mkdir(dirname($permanentFilePath), 0755); + $this->filesystem->rename($tempFilePath, $permanentFilePath, true); + + $chapter->setCbzPath($permanentFilePath); + + $this->entityManager->flush(); + } + + private function createPermanentFileName(Manga $manga, int $volume, ?float $chapterNumber = null): string { $baseFileName = $this->slugger->slug($manga->getTitle()) . '_vol' . sprintf('%02d', $volume); - if (isset($metadata['chapter'])) { - $baseFileName .= '_ch' . $metadata['chapter']; + if ($chapterNumber !== null) { + $baseFileName .= '_ch' . $chapterNumber; } return $baseFileName . '.cbz'; } @@ -77,21 +100,6 @@ class MangaImportService return $directoryPath; } - private function updateChapter(Manga $manga, int $volume, float $chapterNumber, string $cbzPath): void - { - $chapter = $this->chapterRepository->findOneBy([ - 'manga' => $manga, - 'volume' => $volume, - 'number' => $chapterNumber - ]); - - if (!$chapter) { - throw new \RuntimeException("Le chapitre $chapterNumber du volume $volume n'existe pas en base de données."); - } - - $chapter->setCbzPath($cbzPath); - } - private function updateVolumeChapters(Manga $manga, int $volume, string $cbzPath): void { $chapters = $this->chapterRepository->findBy([ diff --git a/templates/conversion/index.html.twig b/templates/conversion/index.html.twig new file mode 100644 index 0000000..f103f7e --- /dev/null +++ b/templates/conversion/index.html.twig @@ -0,0 +1,43 @@ +{% extends 'base.html.twig' %} + +{% block body %} + <div class="container mx-auto p-4"> + <div class="bg-white shadow-lg rounded-sm overflow-hidden"> + <div class="bg-gray-800 text-white p-4"> + <h1 class="text-2xl font-bold"> + <i class="fas fa-exchange-alt mr-2"></i>Convertir CBR en CBZ + </h1> + </div> + <div class="p-6"> + <form method="post" enctype="multipart/form-data" action="{{ path('app_convert') }}" data-turbo="false"> + <div class="mb-4"> + <label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2"> + Choisir un fichier CBR + </label> + <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"> + <div class="space-y-1 text-center"> + <i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i> + <div class="flex text-sm text-gray-600"> + <label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"> + <span>Sélectionner un fichier</span> + <input id="file-upload" name="file" type="file" class="sr-only" accept=".cbr" required> + </label> + <p class="pl-1">ou glisser-déposer</p> + </div> + <p class="text-xs text-gray-500"> + CBR jusqu'à 100MB + </p> + </div> + </div> + </div> + <div class="mt-6"> + <button type="submit" class="w-full flex items-center justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"> + <span class="mr-2">Convertir en CBZ</span> + <i class="fas fa-arrow-right"></i> + </button> + </div> + </form> + </div> + </div> + </div> +{% endblock %} diff --git a/templates/import/index.html.twig b/templates/import/index.html.twig index a3166c7..b0cdb51 100644 --- a/templates/import/index.html.twig +++ b/templates/import/index.html.twig @@ -5,27 +5,27 @@ <div class="bg-white shadow-lg rounded-sm overflow-hidden"> <div class="bg-gray-800 text-white p-4"> <h1 class="text-2xl font-bold"> - <i class="fas fa-file-import mr-2"></i>Importer un Manga + <i class="fas fa-file-import mr-2"></i>Importer des Mangas </h1> </div> <div class="p-6"> <form method="post" enctype="multipart/form-data"> <div class="mb-4"> <label for="file-upload" class="block text-sm font-medium text-gray-700 mb-2"> - Choisir un fichier CBZ + Choisir des fichiers CBZ ou CBR </label> <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"> <div class="space-y-1 text-center"> <i class="fas fa-file-archive text-4xl text-gray-400 mb-3"></i> <div class="flex text-sm text-gray-600"> <label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"> - <span>Sélectionner un fichier</span> - <input id="file-upload" name="file" type="file" class="sr-only" accept=".cbz" required> + <span>Sélectionner des fichiers</span> + <input id="file-upload" name="files[]" type="file" class="sr-only" accept=".cbz,.cbr" multiple required> </label> <p class="pl-1">ou glisser-déposer</p> </div> <p class="text-xs text-gray-500"> - CBZ jusqu'à 100MB + CBZ ou CBR jusqu'à 100MB chacun </p> </div> </div> diff --git a/templates/import/match.html.twig b/templates/import/match.html.twig index d5659ea..28fa31c 100644 --- a/templates/import/match.html.twig +++ b/templates/import/match.html.twig @@ -1,81 +1,103 @@ {% extends 'base.html.twig' %} {% block body %} - <div class="container mx-auto p-4"> - <h1 class="text-2xl font-bold mb-6">Correspondances trouvées :</h1> + <form method="post" action="{{ path('import_confirm') }}" data-controller="import-match"> + <div class="container mx-auto mt-8 p-2"> + <div class="bg-white overflow-hidden"> + <div class="overflow-x-auto"> + <table class="min-w-full bg-white"> + <thead> + <tr class="bg-gray-200 text-gray-800"> + <th class="w-1/12 py-3 px-4 text-left"> + <input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" + data-action="change->import-match#toggleAllCheckboxes"> + </th> + <th class="w-4/12 py-3 px-4 text-left">Original File</th> + <th class="w-4/12 py-3 px-4 text-left">Manga</th> + <th class="w-3/12 py-3 px-4 text-left">Content</th> + <th class="w-2/12 py-3 px-4 text-left">Actions</th> + </tr> + </thead> + <tbody class="text-gray-700"> + {% for file in files %} + <tr class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"> + <td class="py-4 px-4 text-center"> + <input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" + name="selected[]" + value="{{ file.id }}" data-import-match-target="checkbox"> + </td> + <td> + <div class="py-4 px-4"> + <div class="text-sm font-medium text-gray-900">{{ file.originalFileName }}</div> + <div class="text-sm text-gray-500">{{ file.fileSize }}</div> + </div> + </td> + <td class="py-4 px-4"> + <select name="manga_slug[{{ file.id }}]" + class="w-full bg-white border border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" + data-action="change->import-match#updateMangaInfo" + data-file-id="{{ file.id }}"> + {% for manga in file.mangaOptions %} + <option value="{{ manga.slug }}" {% if loop.first %}selected{% endif %} + data-manga-info="{{ manga|json_encode }}">{{ manga.title }}</option> + {% endfor %} + </select> + </td> + <td class="py-4 px-4"> + {% if file.metadata.chapter %} + Chapter {{ file.metadata.chapter }} + <input type="hidden" name="chapter[{{ file.id }}]" + value="{{ file.metadata.chapter }}"> + {% else %} + Volume {{ file.metadata.volume }} + <input type="hidden" name="volume[{{ file.id }}]" + value="{{ file.metadata.volume }}"> + {% endif %} + </td> + <td class="py-4 px-4"> + <button type="button" + class="text-blue-500 hover:text-blue-700 transition duration-150 ease-in-out" + data-action="click->import-match#showDetails" data-file-id="{{ file.id }}"> + <i class="fas fa-info-circle"></i> + </button> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + </div> - {% if mangas %} - {% for manga in mangas %} - <div class="bg-white shadow-lg rounded-lg overflow-hidden mb-8"> - <div class="flex bg-gray-100 p-4"> - <div class="flex-none w-48"> - <img src="{{ manga.imageUrl ?? 'https://placehold.co/150x220' }}" alt="{{ manga.title }}" - class="w-full h-full object-cover rounded" style="width: 150px; height: 220px;"> - </div> - <div class="flex-grow ml-4"> - <h2 class="text-xl font-bold text-gray-900">{{ manga.title }} <span class="text-gray-500">({{ manga.publicationYear }})</span></h2> - <div class="mt-2"> - {% for genre in manga.genres %} - <span class="inline-block bg-gray-200 text-gray-800 text-xs font-semibold mr-2 mb-2 px-2.5 py-0.5 rounded"> - {{ genre }} - </span> - {% endfor %} - </div> - <p class="text-gray-700 text-sm mt-2">{{ manga.description|truncate(150) }}</p> - <div class="mt-2"> - <span class="text-gray-600 text-sm"> - <i class="fas fa-star text-yellow-500"></i> - {{ manga.rating }} - </span> - </div> + <div class="mt-6 flex justify-end"> + <button type="submit" + class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" + data-action="import-match#confirmSelected"> + Importer les fichiers sélectionnés + </button> + </div> + </div> + <div class="fixed z-10 inset-0 overflow-y-auto hidden" data-import-match-target="modal"> + <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + <div class="fixed inset-0 transition-opacity" aria-hidden="true"> + <div class="absolute inset-0 bg-gray-500 opacity-75"></div> + </div> + <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> + <div + class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> + <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> + <div data-import-match-target="modalContent"> + <!-- Will be filled by JavaScript --> </div> </div> - - {% if chapters[manga.slug] is iterable %} - {% for volume, volumeChapters in chapters[manga.slug] %} - {% set is_first = loop.first %} - <div data-controller="table"> - <div class="border-t border-gray-200"> - <div class="bg-gray-50 px-4 py-3 flex justify-between items-center cursor-pointer" data-action="click->table#toggle"> - <h3 class="text-lg font-semibold">Volume {{ '%02d'|format(volume) }}</h3> - <div class="flex items-center"> - <span class="text-gray-600 mr-2">{{ volumeChapters|length }} Chapitres</span> - <i data-table-target="toggleIcon" class="fas fa-chevron-down"></i> - </div> - </div> - <div data-table-target="body" class="" style="display: none;"> - <table class="min-w-full divide-y divide-gray-200"> - <tbody class="bg-white divide-y divide-gray-200"> - {% for chapter in volumeChapters %} - <tr> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ chapter.number }}</td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ chapter.title ?? 'Sans titre' }}</td> - </tr> - {% endfor %} - </tbody> - </table> - </div> - </div> - </div> - {% endfor %} - {% endif %} - <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> - <form method="post" action="{{ path('import_confirm') }}" class="mt-4 sm:mt-0"> - <input type="hidden" name="manga_slug" value="{{ manga.slug }}"> - <input type="hidden" name="volume" value="{{ volume }}"> - <button type="submit" name="action" value="confirm" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm"> - Confirmer - </button> - <button type="submit" name="action" value="refuse" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"> - Refuser - </button> - </form> + <button type="button" + class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" + data-action="click->import-match#closeModal"> + Close + </button> </div> </div> - {% endfor %} - {% else %} - <p class="text-gray-700">Aucune correspondance trouvée.</p> - {% endif %} - </div> + </div> + </div> + </form> {% endblock %} diff --git a/templates/manga/_chapter_list.html.twig b/templates/manga/_chapter_list.html.twig index ec44adf..57f8b7a 100644 --- a/templates/manga/_chapter_list.html.twig +++ b/templates/manga/_chapter_list.html.twig @@ -67,19 +67,25 @@ {% if all_chapters_same_cbz and volume_cbz_path is not null %} <tr class="border-t hover:bg-green-100"> <td class="px-4 py-2 text-green-500"> - <a data-turbo-frame="_top" href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> + <a data-turbo-frame="_top" + href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> {{ '%02d'|format(volume) }} </a> </td> <td class="px-4 py-2 w-full text-left"> - <a data-turbo-frame="_top" href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> + <a data-turbo-frame="_top" + href="{{ path('app_reader', { mangaSlug: manga.slug, chapterNumber: chapters|first.number, pageNumber: 1 }) }}"> Volume {{ '%02d'|format(volume) }} </a> </td> <td class="px-4 py-2 flex justify-end gap-2"> - <a href="{{ path('download_cbz', {chapterId: chapters|first.id}) }}" - class="text-gray-500 hover:text-green-500"> - <i class="fas fa-download"></i> + <a href="#" + data-controller="download" + data-action="download#download" + data-download-url-value="{{ path('download_cbz', {chapterId: chapters|first.id}) }}" + class="w-8 text-center"> + <i data-download-target="icon" + class="fas fa-download text-gray-500 hover:text-green-500"></i> </a> </td> </tr> diff --git a/templates/menu/menu.html.twig b/templates/menu/menu.html.twig index 1b7103d..015ad3c 100644 --- a/templates/menu/menu.html.twig +++ b/templates/menu/menu.html.twig @@ -14,6 +14,12 @@ </ul> {% endif %} </li> + <li class="{{ app.request.get('_route') == 'app_convert' ? 'border-l-4 border-green-600' : '' }}"> + <a href="{{ path('app_convert') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_convert' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> + <i class="fas fa-exchange-alt mr-2"></i> + <span>Convertir CBR en CBZ</span> + </a> + </li> <li class="{{ app.request.get('_route') == 'app_calendar' ? 'border-l-4 border-green-600' : '' }}"> <a href="{{ path('app_calendar') }}" class="block pl-4 py-2 flex items-center {{ app.request.get('_route') == 'app_calendar' ? 'text-green-600 bg-gray-800' : 'hover:bg-gray-700' }}"> <i class="fas fa-calendar-alt mr-2"></i>