From c56f72b813f1eb96c2e0698e601ad7aec5dd7091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Guillot?= Date: Sun, 21 Jul 2024 22:21:04 +0200 Subject: [PATCH] Added: - toogle chapter visibility - delete chapter cbz - preferred ContentSource.php and modal - minor fixes --- .../preferred_sources_controller.js | 101 ++++++++++++++++++ assets/controllers/toolbar_controller.js | 5 + migrations/Version20240721172633.php | 38 +++++++ migrations/Version20240721190312.php | 32 ++++++ package-lock.json | 7 ++ package.json | 1 + src/Controller/MangaController.php | 100 ++++++++++++----- src/Entity/Chapter.php | 15 +++ src/Entity/ContentSource.php | 9 ++ src/Entity/Manga.php | 38 +++++++ .../Toolbar/Definition/ChapterListToolbar.php | 2 +- src/MessageHandler/DownloadChapterHandler.php | 13 ++- src/Service/MangadexProvider.php | 1 - src/Service/Scraper/JavascriptScraper.php | 3 +- templates/manga/_chapter_list.html.twig | 2 +- templates/manga/_chapter_row.html.twig | 97 ++++++++++------- templates/manga/_manga_details.html.twig | 92 ++++++++++++++-- 17 files changed, 474 insertions(+), 82 deletions(-) create mode 100644 assets/controllers/preferred_sources_controller.js create mode 100644 migrations/Version20240721172633.php create mode 100644 migrations/Version20240721190312.php diff --git a/assets/controllers/preferred_sources_controller.js b/assets/controllers/preferred_sources_controller.js new file mode 100644 index 0000000..3a4c6e1 --- /dev/null +++ b/assets/controllers/preferred_sources_controller.js @@ -0,0 +1,101 @@ +// assets/controllers/preferred-sources_controller.js + +import {Controller} from "@hotwired/stimulus" +import Sortable from 'sortablejs' + +export default class extends Controller { + static targets = ["preferredList", "availableList"] + static values = { + mangaId: Number, + preferredSources: Array, + allSources: Array + } + + connect() { + this.initSortable() + } + + initSortable() { + new Sortable(this.preferredListTarget, { + animation: 150, + ghostClass: 'bg-gray-300', + onEnd: this.handleDragEnd.bind(this) + }) + } + + handleDragEnd() { + this.updatePreferredSources() + } + + addSource(event) { + const sourceId = parseInt(event.currentTarget.dataset.sourceId) + if (!this.preferredSourcesValue.includes(sourceId)) { + this.preferredSourcesValue = [...this.preferredSourcesValue, sourceId] + this.updateLists() + this.save() + } + } + + removeSource(event) { + const sourceId = parseInt(event.currentTarget.dataset.sourceId) + this.preferredSourcesValue = this.preferredSourcesValue.filter(id => id !== sourceId) + this.updateLists() + this.save() + } + + updatePreferredSources() { + this.preferredSourcesValue = Array.from(this.preferredListTarget.children).map(li => parseInt(li.dataset.id)) + this.save() + } + + updateLists() { + this.preferredListTarget.innerHTML = this.preferredSourcesValue + .map(id => this.allSourcesValue.find(s => s.id === id)) + .map(source => this.sourceTemplate(source, true)) + .join('') + + this.availableListTarget.innerHTML = this.allSourcesValue + .filter(source => !this.preferredSourcesValue.includes(source.id)) + .map(source => this.sourceTemplate(source, false)) + .join('') + + this.initSortable() + } + + sourceTemplate(source, isPreferred) { + return ` +
  • + ${source.name} + +
  • + ` + } + + async save() { + try { + const response = await fetch(`/manga/${this.mangaIdValue}/preferred-sources`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + preferredSources: this.preferredSourcesValue + }) + }) + + if (response.ok) { + console.log('Preferred sources saved successfully') + // Optionally show a success message + } else { + console.error('Error saving preferred sources') + // Optionally show an error message + } + } catch (error) { + console.error('Error:', error) + // Optionally show an error message + } + } +} diff --git a/assets/controllers/toolbar_controller.js b/assets/controllers/toolbar_controller.js index 31e591b..64ed385 100644 --- a/assets/controllers/toolbar_controller.js +++ b/assets/controllers/toolbar_controller.js @@ -94,6 +94,11 @@ export default class extends Controller { document.dispatchEvent(event); } + editPreferredSources() { + const event = new CustomEvent('openPreferredSourcesModal'); + document.dispatchEvent(event); + } + deleteMangas() { console.log("Deleting mangas..."); } diff --git a/migrations/Version20240721172633.php b/migrations/Version20240721172633.php new file mode 100644 index 0000000..254ef14 --- /dev/null +++ b/migrations/Version20240721172633.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE manga_content_source (manga_id INT NOT NULL, content_source_id INT NOT NULL, PRIMARY KEY(manga_id, content_source_id))'); + $this->addSql('CREATE INDEX IDX_2C40CB647B6461 ON manga_content_source (manga_id)'); + $this->addSql('CREATE INDEX IDX_2C40CB64A5DB5462 ON manga_content_source (content_source_id)'); + $this->addSql('ALTER TABLE manga_content_source ADD CONSTRAINT FK_2C40CB647B6461 FOREIGN KEY (manga_id) REFERENCES manga (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE manga_content_source ADD CONSTRAINT FK_2C40CB64A5DB5462 FOREIGN KEY (content_source_id) REFERENCES content_source (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE manga_content_source DROP CONSTRAINT FK_2C40CB647B6461'); + $this->addSql('ALTER TABLE manga_content_source DROP CONSTRAINT FK_2C40CB64A5DB5462'); + $this->addSql('DROP TABLE manga_content_source'); + } +} diff --git a/migrations/Version20240721190312.php b/migrations/Version20240721190312.php new file mode 100644 index 0000000..4c168af --- /dev/null +++ b/migrations/Version20240721190312.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE chapter ADD visible BOOLEAN NOT NULL DEFAULT TRUE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE chapter DROP visible'); + } +} diff --git a/package-lock.json b/package-lock.json index 52f9d87..e3d396a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "bootstrap": "^5.3.3", "postcss-loader": "^7.1.0", "puppeteer": "^22.10.0", + "sortablejs": "^1.15.2", "tailwindcss": "^3.2.7" }, "devDependencies": { @@ -9157,6 +9158,11 @@ "node": ">= 14" } }, + "node_modules/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10661,6 +10667,7 @@ } }, "vendor/symfony/ux-turbo/assets": { + "name": "@symfony/ux-turbo", "version": "0.1.0", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index 34fbca9..3a90333 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "bootstrap": "^5.3.3", "postcss-loader": "^7.1.0", "puppeteer": "^22.10.0", + "sortablejs": "^1.15.2", "tailwindcss": "^3.2.7" } } diff --git a/src/Controller/MangaController.php b/src/Controller/MangaController.php index 3bfbbcd..da49dd9 100644 --- a/src/Controller/MangaController.php +++ b/src/Controller/MangaController.php @@ -9,6 +9,7 @@ use App\Manager\Toolbar\Factory\ToolbarFactory; use App\Message\DownloadChapter; use App\Message\RefreshMetadata; use App\Repository\ChapterRepository; +use App\Repository\ContentSourceRepository; use App\Repository\MangaRepository; use App\Service\CbzService; use App\Service\MangadexProvider; @@ -37,16 +38,17 @@ class MangaController extends AbstractController private ImageManager $imageManager; public function __construct( - private readonly string $projectDir, - private readonly MangaRepository $mangaRepository, - private readonly ChapterRepository $chapterRepository, - private readonly MessageBusInterface $bus, - private readonly CbzService $cbzService, - private readonly ToolbarFactory $toolbarFactory, - private readonly MangadexProvider $mangadexProvider, - private readonly EntityManagerInterface $entityManager, - private readonly NotificationService $notificationService, - private readonly SluggerInterface $slugger + private readonly string $projectDir, + private readonly MangaRepository $mangaRepository, + private readonly ChapterRepository $chapterRepository, + private readonly MessageBusInterface $bus, + private readonly CbzService $cbzService, + private readonly ToolbarFactory $toolbarFactory, + private readonly MangadexProvider $mangadexProvider, + private readonly EntityManagerInterface $entityManager, + private readonly NotificationService $notificationService, + private readonly SluggerInterface $slugger, + private readonly ContentSourceRepository $contentSourceRepository ) { $this->imageManager = new ImageManager(new Driver()); @@ -81,11 +83,13 @@ class MangaController extends AbstractController } $form = $this->createForm(MangaEditType::class, $manga); + $contentSources = $this->contentSourceRepository->findAll(); return $this->render('manga/show_chapters.html.twig', [ 'manga' => $manga, - 'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int) $manga->isMonitored()])->getGroups(), + 'toolbar' => $this->toolbarFactory->createToolbar('chapter_list', ['mangaId' => $manga->getId(), 'isMonitored' => (int)$manga->isMonitored()])->getGroups(), 'form' => $form->createView(), + 'contentSources' => $contentSources, ]); } @@ -94,6 +98,7 @@ class MangaController extends AbstractController { try { foreach ($manga->getChapters() as $chapter) { + file_exists($chapter->getCbzPath()) ?? unlink($chapter->getCbzPath()); $this->entityManager->remove($chapter); } $this->entityManager->remove($manga); @@ -125,6 +130,30 @@ class MangaController extends AbstractController return new JsonResponse(['errors' => $errors], 400); } + #[Route('/manga/{id}/preferred-sources', name: 'manga_preferred_sources', methods: ['POST'])] + public function updatePreferredSources( + Request $request, + Manga $manga, + ContentSourceRepository $contentSourceRepository + ): JsonResponse + { + $data = json_decode($request->getContent(), true); + $preferredSourceIds = $data['preferredSources'] ?? []; + + $preferredSources = $contentSourceRepository->findBy(['id' => $preferredSourceIds]); + + // This will maintain the order of the sources as they were sent in the request + $orderedPreferredSources = array_map( + fn($id) => current(array_filter($preferredSources, fn($s) => $s->getId() == $id)), + $preferredSourceIds + ); + + $manga->setPreferredSources(array_filter($orderedPreferredSources)); + $this->entityManager->flush(); + + return new JsonResponse(['success' => true]); + } + public function _chaptersByManga(int $id): Response { $manga = $this->mangaRepository->find($id); @@ -157,6 +186,33 @@ class MangaController extends AbstractController ]); } + #[Route('/delete_cbz/{id}', name: 'app_delete_cbz')] + public function deleteChapterCbz(Chapter $chapter): JsonResponse + { + $cbzPath = $chapter->getCbzPath(); + if (!$cbzPath) { + return new JsonResponse(['error' => 'No CBZ path for this chapter.'], 400); + } + + file_exists($cbzPath) ?? unlink($cbzPath); + + $chapter->setCbzPath(null); + $this->entityManager->persist($chapter); + $this->entityManager->flush(); + + return new JsonResponse(['success' => 'CBZ file deleted.'], 200); + } + + #[Route('/hide_chapter/{id}', name: 'app_hide_chapter')] + public function hideChapter(Chapter $chapter): JsonResponse + { + $chapter->setVisible(false); + $this->entityManager->persist($chapter); + $this->entityManager->flush(); + + return new JsonResponse(['success' => 'Chapter hidden.'], 200); + } + #[Route('/manga/read/{mangaSlug}/{chapterNumber}/{pageNumber}', name: 'app_manga_read')] public function readChapterPage(string $mangaSlug, float $chapterNumber, int $pageNumber = 1): Response { @@ -171,17 +227,7 @@ class MangaController extends AbstractController } if (is_null($chapter->getCbzPath())) { - $currentPage = $chapter->getPageByNumber($pageNumber); - if (!$currentPage) { - throw $this->createNotFoundException("La page demandée n'existe pas."); - } - - return $this->render('manga/manga_reader.html.twig', [ - 'manga' => $manga, - 'chapter' => $chapter, - 'pages' => $chapter->getPagesLink(), - 'currentPage' => $currentPage, - ]); + throw $this->createNotFoundException("Le chapitre demandé n'a pas été scrapé."); } $pageContent = $this->cbzService->getPageContent($chapter->getCbzPath(), $pageNumber); @@ -226,7 +272,9 @@ class MangaController extends AbstractController ->setAuthor($request->request->get('author')) ->setPublicationYear($request->request->get('publicationYear')) ->setRating($request->request->get('rating')) - ->setExternalId($request->request->get('externalId')); + ->setExternalId($request->request->get('externalId')) + ->setMonitored(false) + ; // Traitement de l'image $imageUrl = $request->request->get('imageUrl'); @@ -327,7 +375,8 @@ class MangaController extends AbstractController $volumeChapters = $this->chapterRepository->findBy([ 'manga' => $manga, - 'volume' => $volume + 'volume' => $volume, + 'visible' => true ]); if (empty($volumeChapters)) { @@ -374,7 +423,8 @@ class MangaController extends AbstractController $volumeChapters = $this->chapterRepository->findBy([ 'manga' => $manga, - 'volume' => $volume + 'volume' => $volume, + 'visible' => true ]); if (empty($volumeChapters)) { diff --git a/src/Entity/Chapter.php b/src/Entity/Chapter.php index 07d4ccc..865136e 100644 --- a/src/Entity/Chapter.php +++ b/src/Entity/Chapter.php @@ -45,6 +45,9 @@ class Chapter #[ORM\Column(length: 255, nullable: true)] private ?string $cbzPath = null; + #[ORM\Column] + private ?bool $visible = true; + public function __construct() { $this->pagesLink = new ArrayCollection(); @@ -194,4 +197,16 @@ class Chapter return $this; } + + public function isVisible(): ?bool + { + return $this->visible; + } + + public function setVisible(bool $visible): static + { + $this->visible = $visible; + + return $this; + } } diff --git a/src/Entity/ContentSource.php b/src/Entity/ContentSource.php index b98d8ca..73b91ce 100644 --- a/src/Entity/ContentSource.php +++ b/src/Entity/ContentSource.php @@ -118,4 +118,13 @@ class ContentSource return $this; } + + public function getCleanBaseUrl(): string + { + return preg_replace( + '/^(https?:\/\/)?(www\.)?|\/+$/', + '', + $this->baseUrl + ); + } } diff --git a/src/Entity/Manga.php b/src/Entity/Manga.php index 7dcd28c..29a47e1 100644 --- a/src/Entity/Manga.php +++ b/src/Entity/Manga.php @@ -62,10 +62,14 @@ class Manga #[ORM\Column(type: Types::JSON, nullable: true)] private ?array $AlternativeSlugs = null; + #[ORM\ManyToMany(targetEntity: ContentSource::class)] + private Collection $preferredSources; + public function __construct() { $this->chapters = new ArrayCollection(); $this->createdAt = new \DateTimeImmutable(); + $this->preferredSources = new ArrayCollection(); } public function getId(): ?int @@ -280,4 +284,38 @@ class Manga return $this; } + + /** + * @return Collection + */ + public function getPreferredSources(): Collection + { + return $this->preferredSources; + } + + public function addPreferredSource(ContentSource $preferredSource): static + { + if (!$this->preferredSources->contains($preferredSource)) { + $this->preferredSources->add($preferredSource); + } + + return $this; + } + + public function removePreferredSource(ContentSource $preferredSource): static + { + $this->preferredSources->removeElement($preferredSource); + + return $this; + } + + public function setPreferredSources(array $sources): self + { + $this->preferredSources->clear(); + foreach ($sources as $source) { + $this->addPreferredSource($source); + } + + return $this; + } } diff --git a/src/Manager/Toolbar/Definition/ChapterListToolbar.php b/src/Manager/Toolbar/Definition/ChapterListToolbar.php index 928b3ea..77a586d 100644 --- a/src/Manager/Toolbar/Definition/ChapterListToolbar.php +++ b/src/Manager/Toolbar/Definition/ChapterListToolbar.php @@ -17,7 +17,7 @@ class ChapterListToolbar extends Toolbar ->addToLeftGroup(new ToolbarDivider()) ->addToLeftGroup(new ToolbarButton('keyboard', 'Rename chapters', 'toolbar#renameChapters')) ->addToLeftGroup(new ToolbarButton('file-zipper', 'Manage cbz', 'toolbar#manageCbz', $contextData)) - ->addToLeftGroup(new ToolbarButton('history', 'History', 'toolbar#history', $contextData)) + ->addToLeftGroup(new ToolbarButton('gear', 'Preferred Sources', 'toolbar#editPreferredSources', $contextData)) ->addToRightGroup(new ToolbarButton('bookmark', $monitoredTitle, 'toolbar#monitoring', array_merge($contextData, ['buttonClass' => $monitoredColor]))) diff --git a/src/MessageHandler/DownloadChapterHandler.php b/src/MessageHandler/DownloadChapterHandler.php index 79de201..5595870 100644 --- a/src/MessageHandler/DownloadChapterHandler.php +++ b/src/MessageHandler/DownloadChapterHandler.php @@ -6,8 +6,8 @@ use App\Entity\ContentSource; use App\Message\DownloadChapter; use App\Repository\ChapterRepository; use App\Repository\ContentSourceRepository; -use App\Service\MangaScraperService; use App\Service\NotificationService; +use App\Service\Scraper\MangaScraperService; use Exception; use GuzzleHttp\Exception\GuzzleException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -40,7 +40,16 @@ readonly class DownloadChapterHandler throw new BadRequestHttpException('Chapter already downloaded'); } - $sources = $this->contentSourceRepository->findAll(); + $manga = $chapter->getManga(); + $preferredSources = $manga->getPreferredSources()->toArray(); + $allSources = $this->contentSourceRepository->findAll(); + + $filteredSources = array_udiff($allSources, $preferredSources, function ($a, $b) { + return $a->getId() - $b->getId(); + }); + + $sources = array_merge($preferredSources, $filteredSources); + $sources[] = (new ContentSource()) ->setBaseUrl('https://api.mangadex.org/') diff --git a/src/Service/MangadexProvider.php b/src/Service/MangadexProvider.php index 3d0f7f7..41f98c9 100644 --- a/src/Service/MangadexProvider.php +++ b/src/Service/MangadexProvider.php @@ -227,7 +227,6 @@ readonly class MangadexProvider implements MetadataProviderInterface $mergedChapters = []; foreach ($allChapters as $chapter) { $number = $chapter->getNumber(); - $existingChapter = $manga->getChapterByNumber($number); if ($existingChapter) { if ($existingChapter->getExternalId() !== $chapter->getExternalId() && is_null($existingChapter->getExternalId())) { diff --git a/src/Service/Scraper/JavascriptScraper.php b/src/Service/Scraper/JavascriptScraper.php index fc11115..22e8e13 100644 --- a/src/Service/Scraper/JavascriptScraper.php +++ b/src/Service/Scraper/JavascriptScraper.php @@ -37,6 +37,7 @@ class JavascriptScraper extends AbstractScraper $imagePath = $tempDir . '/' . $imageName; file_put_contents($imagePath, file_get_contents($page['image_url'])); + $this->dispatchProgressEvent($chapter, $index + 1, count($pageData)); $page['local_image_url'] = $imagePath; } @@ -95,7 +96,7 @@ class JavascriptScraper extends AbstractScraper { $chapterSelector = $contentSource->getChapterSelector(); if (!$chapterSelector) { - return; // Si aucun sélecteur n'est défini, on ne fait rien + return; } $crawler = $pantherClient->waitFor($chapterSelector); diff --git a/templates/manga/_chapter_list.html.twig b/templates/manga/_chapter_list.html.twig index 9376682..b485d49 100644 --- a/templates/manga/_chapter_list.html.twig +++ b/templates/manga/_chapter_list.html.twig @@ -5,7 +5,7 @@ {% set volume_cbz_path = chapters|first.cbzPath %} {% set all_chapters_same_cbz = chapters|reduce((carry, chapter) => carry and chapter.cbzPath == volume_cbz_path, true) %} {% set available_chapters = chapters|filter(chapter => chapter.cbzPath is not null) %} - {% set total_chapters = chapters|length %} + {% set total_chapters = chapters|filter(chapter => chapter.visible)|length %}
    diff --git a/templates/manga/_chapter_row.html.twig b/templates/manga/_chapter_row.html.twig index 16c92b8..a57e3e4 100644 --- a/templates/manga/_chapter_row.html.twig +++ b/templates/manga/_chapter_row.html.twig @@ -1,48 +1,65 @@ - - {% if chapter.cbzPath is not null %} - - - {{ '%02d'|format(chapter.number) }} - - - {% else %} - {{ '%02d'|format(chapter.number) }} - {% endif %} - - +{% if chapter.visible %} + {% if chapter.cbzPath is not null %} - - {{ chapter.title ?? 'No title' }} - + + + {{ chapter.number < 10 ? '0' ~ chapter.number : chapter.number }} + + {% else %} - {{ chapter.title ?? 'No title' }} + {{ chapter.number < 10 ? '0' ~ chapter.number : chapter.number }} {% endif %} - - - {% if chapter.cbzPath is null %} - - {% else %} - + {% else %} + + {% endif %} + + + + - {% endif %} - - - - - + + +{% endif %} diff --git a/templates/manga/_manga_details.html.twig b/templates/manga/_manga_details.html.twig index 7b3578f..6a7370a 100644 --- a/templates/manga/_manga_details.html.twig +++ b/templates/manga/_manga_details.html.twig @@ -49,7 +49,9 @@
    + src="{{ fragment_uri(controller('App\\Controller\\MangaController::_chaptersByManga', {'id': manga.id})) }}" + > + {# Modal d'édition #} Slugs alternatifs
    {% for slug in form.alternativeSlugs %} -
    +
    {{ form_widget(slug, {'attr': {'class': 'bg-transparent border-none focus:outline-none focus:border-b focus:border-green-500 p-0 w-full'}}) }} -
    {% endfor %}
    -