40 Commits

Author SHA1 Message Date
810e18c26c Merge pull request 'fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy' (#49) from fix/mercure-transport-directive into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #49
2026-04-10 15:24:55 +02:00
Jérémy Guillot
1905581214 fix(mercure): utiliser la nouvelle syntaxe transport bolt pour Caddy
La directive transport_url a été supprimée dans les versions récentes
de Mercure, remplacée par un sous-bloc transport bolt { url ... }.
2026-04-10 15:23:42 +02:00
c0ab40eacd Merge pull request 'fix(manga): conserver le padding du numéro de chapitre après scraping' (#48) from fix/chapter-number-padding-after-scraping into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #48
2026-04-09 15:11:56 +02:00
Jérémy Guillot
e214e1ea46 fix(manga): conserver le padding du numéro de chapitre après scraping 2026-04-09 15:11:23 +02:00
1f1efd1b16 Merge pull request 'fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages' (#47) from fix/download-cbz-from-pages-directory into main
All checks were successful
Deploy / deploy (push) Successful in 1m11s
Reviewed-on: #47
2026-04-09 14:49:41 +02:00
Jérémy Guillot
41c1fc5e2e fix(manga): générer le CBZ de téléchargement depuis les dossiers de pages
Les endpoints de téléchargement chapitre/volume plantaient (500 "file does
not exist") car le FileService traitait `pagesDirectory` comme un CBZ. Le
service reconstruit maintenant l'archive à la volée à partir des images du
dossier, et le nom du fichier chapitre inclut le titre du manga et le numéro.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:48:17 +02:00
848efd3327 Merge pull request 'feat(home): toolbar filtre/affichage et modale options d'affichage' (#46) from feat/home-toolbar-display-settings into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #46
2026-03-27 16:26:33 +01:00
65eef59999 Merge branch 'main' into feat/home-toolbar-display-settings 2026-03-27 16:26:20 +01:00
ext.jeremy.guillot@maxicoffee.domains
e525c9b7bd feat(home): toolbar filtre/affichage et modale options d'affichage
- Correction du dropdown toolbar : prop align (left/right) pour éviter le débordement hors écran côté droit
- Filtre de collection par statut (all/completed/ongoing) persisté dans userPreferencesStore
- toolbarConfig rendu réactif (computed) avec isSelected sur Filter, Sort et View
- Modale Options d'affichage par vue (Grille, Overview, Table) avec toggles persistés
- Composant ToggleRow réutilisable
- Normalisation author → authors dans l'entité Manga (l'API renvoie author string)
2026-03-27 16:25:45 +01:00
8d8389377d Merge pull request 'fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex' (#45) from fix/monitoring-chapter-id-mismatch into main
All checks were successful
Deploy / deploy (push) Successful in 1m5s
Reviewed-on: #45
2026-03-27 15:04:20 +01:00
a9c5769c8e Merge branch 'main' into fix/monitoring-chapter-id-mismatch 2026-03-27 15:04:12 +01:00
ext.jeremy.guillot@maxicoffee.domains
969f4569f5 fix(monitoring): corriger la résolution de l'ID chapitre après synchronisation MangaDex
synchronizeChapters() retournait des UUID temporaires générés en mémoire. Ces UUID
n'étant jamais persistés, le Scraping domain ne pouvait pas retrouver le chapitre
(SQLSTATE 22P02 : invalid input syntax for type integer).

- ChapterSynchronizationServiceInterface : retourne float[] (numéros) au lieu de string[] (UUID)
- MangadxChapterSynchronizationService : retourne getNumber() au lieu de getId()
- RefreshMangaChaptersHandler : après save(), retrouve chaque chapitre par manga+numéro
  via findChapterByMangaIdAndNumber() pour obtenir le vrai PK integer avant de dispatcher
  ChapterReadyForScraping
2026-03-27 15:03:05 +01:00
13eac6954d Merge pull request 'fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas' (#44) from fix/monitoring-missing-symfony-handler into main
All checks were successful
Deploy / deploy (push) Successful in 1m7s
Reviewed-on: #44
2026-03-27 14:36:25 +01:00
ext.jeremy.guillot@maxicoffee.domains
7e6bacd934 fix(monitoring): ajouter le handler Symfony manquant pour CheckMonitoredMangas
Sans ce wrapper #[AsMessageHandler], Messenger ne trouvait aucun handler pour
le message CheckMonitoredMangas — le scheduler et la commande console échouaient
silencieusement avec NoHandlerForMessageException.
2026-03-27 14:35:47 +01:00
d1279c90cc Merge pull request 'fix(deploy): corriger la race condition sur le cache prod au déploiement' (#43) from fix/deploy-cache-race-condition into main
All checks were successful
Deploy / deploy (push) Successful in 1m6s
Reviewed-on: #43
2026-03-27 14:29:06 +01:00
a0729d2e6e Merge branch 'main' into fix/deploy-cache-race-condition 2026-03-27 14:28:58 +01:00
ext.jeremy.guillot@maxicoffee.domains
f47d1a245f fix(deploy): corriger la race condition sur le cache prod au déploiement
L'entrypoint faisait rm -rf var/cache/prod puis lançait FrankenPHP.
FrankenPHP compilait partiellement le container DI pendant que le script
Deployer lançait aussi cache:clear → fichiers manquants → crash.

- entrypoint.sh : ajouter cache:warmup après rm -rf, avant exec FrankenPHP
  (l'entrypoint est séquentiel, FrankenPHP ne démarre qu'une fois le cache prêt)
- deploy.php : supprimer le docker exec cache:clear devenu inutile et dangereux
2026-03-27 14:28:30 +01:00
78cc83d465 Merge pull request 'feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement' (#42) from feat/monitoring-run-command into main
Some checks failed
Deploy / deploy (push) Failing after 1m7s
Reviewed-on: #42
2026-03-27 14:23:30 +01:00
7204ea7754 Merge branch 'main' into feat/monitoring-run-command 2026-03-27 14:23:21 +01:00
ext.jeremy.guillot@maxicoffee.domains
f42b5a9cf5 feat(monitoring): ajouter une commande console pour déclencher le monitoring manuellement
Permet de tester le scheduler en prod sans attendre le cycle de 2h :
  make sf c="app:monitoring:run"
2026-03-27 14:21:05 +01:00
5edd28309f Merge pull request 'fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres' (#41) from fix/monitoring-scheduler-since-frozen into main
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Reviewed-on: #41
2026-03-27 12:08:33 +01:00
ext.jeremy.guillot@maxicoffee.domains
3f08e1c899 fix(monitoring): corriger le scheduler qui ne détectait plus les nouveaux chapitres
- MonitoringSchedule : supprimer la date passée au message (était évaluée une
  seule fois au démarrage du container, rendant la requête caduque après le
  premier cycle)
- CheckMonitoredMangasHandler : calculer `since` dynamiquement à l'exécution
  (`new \DateTimeImmutable('-2 hours')`) plutôt que de dépendre du message
- AutoScrapingListener : corriger le TypeError silencieux — créer un ScrapingJob
  avant d'appeler ScrapeChapterHandler (paramètre jobId manquant)

Ajoute les tests unitaires CheckMonitoredMangasHandlerTest et AutoScrapingListenerTest.
2026-03-27 12:08:06 +01:00
214f470e77 Merge pull request 'fix(manga): afficher le titre du chapitre téléchargé individuellement' (#40) from fix/chapter-title-downloaded into main
All checks were successful
Deploy / deploy (push) Successful in 1m9s
Reviewed-on: #40
2026-03-27 11:30:16 +01:00
ext.jeremy.guillot@maxicoffee.domains
345434c25d fix(manga): afficher le titre du chapitre téléchargé individuellement
Quand un chapitre téléchargé est seul dans son groupe (volumeChapterCount === 1),
on affichait "Chapitre 42" au lieu du titre réel. La condition isVolumeGroup
s'appliquait même pour les groupes à un seul élément.

Fix : la mise en forme "Chapitres X-Y" n'est désormais appliquée que lorsque
volumeChapterCount > 1, sinon on affiche chapter.title comme pour les chapitres
non téléchargés.
2026-03-27 11:29:13 +01:00
2868772f5c Merge pull request 'fix(deploy): vider le cache prod au démarrage du conteneur' (#39) from fix/entrypoint-clear-cache into main
All checks were successful
Deploy / deploy (push) Successful in 1m9s
Reviewed-on: #39
2026-03-26 18:50:15 +01:00
a2469b6c07 Merge branch 'main' into fix/entrypoint-clear-cache 2026-03-26 18:50:08 +01:00
ext.jeremy.guillot@maxicoffee.domains
926f938c45 fix(deploy): vider le cache prod au démarrage du conteneur
Sans ce fix, les workers FrankenPHP démarrent avec l'ancien cache persisté
dans le volume Docker. Si les classes référencées (ex. EntityManagerGhost,
LazyGhostTrait) ne correspondent plus à la version déployée, les workers
crashent en boucle, rendant le conteneur instable et faisant échouer le
cache:clear lancé ensuite par Deployer (exit 137).

La suppression de var/cache/prod à l'entrypoint garantit que les workers
démarrent toujours sur un cache vierge, généré à chaud à la première requête.
2026-03-26 18:49:30 +01:00
5551d73962 Merge pull request 'fix: limiter les workers FrankenPHP et nettoyer le Dockerfile' (#38) from fix/cache-clear-oom into main
Some checks failed
Deploy / deploy (push) Failing after 35s
Reviewed-on: #38
2026-03-26 18:44:26 +01:00
395a0a16cb Merge branch 'main' into fix/cache-clear-oom 2026-03-26 18:44:17 +01:00
ext.jeremy.guillot@maxicoffee.domains
8e2e608ad9 fix: limiter les workers FrankenPHP et nettoyer le Dockerfile
- worker.Caddyfile : limiter à 2 workers FrankenPHP pour éviter l'OOM
  lors du cache:clear en prod (chaque worker charge le kernel Symfony
  complet, la valeur par défaut = nb de CPUs était trop élevée)
- Dockerfile : supprimer les COPY des assets UX (ux-live-component,
  ux-react, ux-turbo) supprimés de composer.json
2026-03-26 18:43:51 +01:00
0f80cb9fec Merge pull request 'fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir' (#37) from fix/doctrine-orm3-config into main
Some checks failed
Deploy / deploy (push) Failing after 36s
Reviewed-on: #37
2026-03-26 18:39:56 +01:00
a3477629fb Merge branch 'main' into fix/doctrine-orm3-config 2026-03-26 18:39:51 +01:00
ext.jeremy.guillot@maxicoffee.domains
cde701986e fix(doctrine): supprimer auto_generate_proxy_classes et proxy_dir
Ces options ont été supprimées de Doctrine Bundle 3.x / ORM 3.x.
Elles causaient une erreur "Unrecognized options" au cache:clear en prod.
2026-03-26 18:39:22 +01:00
b921768aef Merge pull request 'chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées' (#36) from chore/cleanup-unused-dependencies into main
Some checks failed
Deploy / deploy (push) Failing after 1m10s
Reviewed-on: #36
2026-03-26 18:36:11 +01:00
ext.jeremy.guillot@maxicoffee.domains
5f0178f784 chore: supprimer les dépendances Twig/Stimulus/React/Turbo inutilisées
PHP : suppression de symfony/stimulus-bundle, ux-live-component, ux-react,
ux-turbo, twig/extra-bundle et symfony/form (plus utilisés depuis la
migration vers Vue.js SPA).

npm : suppression de @hotwired/stimulus, @hotwired/turbo, react, react-dom,
alpinejs, bootstrap, daisyui, sortablejs, vuedraggable et leurs types.
Corrige l'erreur de déploiement causée par vitest@^4.1.0 (introuvable)
requis par les anciens packages @symfony/ux-react et @symfony/ux-turbo v2.33.0.
2026-03-26 18:35:40 +01:00
c610d22bd2 Merge pull request 'feature/upgrade-symfony-8' (#35) from feature/upgrade-symfony-8 into main
Some checks failed
Deploy / deploy (push) Failing after 21s
Reviewed-on: #35
2026-03-26 18:23:13 +01:00
ab2cf319ac Merge branch 'main' into feature/upgrade-symfony-8 2026-03-26 18:23:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
69c6757cf8 fix: corriger l'erreur HTTP 400 sur les endpoints content-sources POST/PUT
- ContentSourceForm.vue : convertir testChapterNumber en float/null avant
  envoi (évite d'envoyer "" pour ?float, rejeté par Symfony 8 strict)
- UpsertContentSourceResource : ajouter collectDenormalizationErrors: true
  pour que les erreurs de type retournent 422 au lieu de 400 via le
  chemin input: de API Platform 4
- ContentSource entity : corriger setImageSelector(string) → setImageSelector(?string)
  cohérent avec la colonne nullable
- Ajouter les tests manquants (testChapterNumber float/null/chaîne vide)
  qui auraient détecté ces bugs plus tôt
2026-03-26 18:22:31 +01:00
ext.jeremy.guillot@maxicoffee.domains
21d8111734 fix: migrer les données manga.genres de PHP sérialisé vers JSON
La migration vers DBAL 4 a changé le type de colonne genres de
Types::ARRAY (PHP sérialisé) vers Types::JSON. Les données existantes
en base doivent être converties via preUp() avant l'ALTER TABLE.
2026-03-26 17:58:07 +01:00
ext.jeremy.guillot@maxicoffee.domains
5ed303612a feat: migrer vers Symfony 8, PHP 8.4 et les dépendances majeures associées
- PHP 8.3 → 8.4 (Dockerfile + composer.json)
- Symfony 7.0 → 8.0 (tous les composants symfony/*)
- API Platform 3.x → 4.x : migration openapiContext → openapi: new Operation(...)
- Doctrine DBAL 3 → 4 : suppression use_savepoints, replace prepare/executeQuery
- Doctrine ORM 2.x → 3.x : ClassMetadataInfo → ClassMetadata, setParameters → setParameter
- Doctrine Bundle 2.x → 3.x, Fixtures Bundle 3.x → 4.x
- zenstruck/foundry 1.x → 2.x : ModelFactory → PersistentObjectFactory, getDefaults → defaults
- phpmd/phpmd 2.x → 3.x-dev (seule version supportant Symfony 8)
- phparkitect 0.3 → 0.8 : NotDependsOnTheseNamespaces prend un array
- symfony/mercure-bundle 0.3 → 0.4, symfony/monolog-bundle 3 → 4
- Suppression de runtime/frankenphp-symfony (intégré nativement dans symfony/runtime 8)
- worker.Caddyfile : suppression de APP_RUNTIME (détection automatique Symfony 8)
- Routes errors.xml/wdt.xml/profiler.xml → .php (Symfony 8 supprime le XML)
- Types::ARRAY → Types::JSON dans Entity/Manga.php (DBAL 4 retire array type)
- Suppression de src/Schedule.php (doublon vide avec MonitoringSchedule)
- Tests : hydra:Collection → Collection, hydra:member → member (API Platform 4)
2026-03-26 17:55:12 +01:00
399 changed files with 6889 additions and 8463 deletions

View File

@@ -1,7 +1,7 @@
#syntax=docker/dockerfile:1.4
# Versions
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
@@ -108,9 +108,6 @@ RUN composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scri
FROM node:22-alpine AS node_build
WORKDIR /app
COPY --link package.json package-lock.json ./
COPY --from=composer_deps /app/vendor/symfony/ux-live-component/assets ./vendor/symfony/ux-live-component/assets
COPY --from=composer_deps /app/vendor/symfony/ux-react/assets ./vendor/symfony/ux-react/assets
COPY --from=composer_deps /app/vendor/symfony/ux-turbo/assets ./vendor/symfony/ux-turbo/assets
RUN npm install
COPY --link assets ./assets
COPY --link webpack.config.js ./

View File

@@ -4,6 +4,7 @@ export class Manga {
slug,
title,
description = null,
author = null,
authors = [],
imageUrl = null,
thumbnailUrl = null,
@@ -20,7 +21,7 @@ export class Manga {
this.slug = slug;
this.title = title;
this.description = description;
this.authors = authors;
this.authors = authors.length ? authors : (author ? [author] : []);
this.imageUrl = imageUrl;
this.thumbnailUrl = thumbnailUrl;
this.publicationYear = publicationYear;

View File

@@ -0,0 +1,161 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="$emit('close')">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-100">
Options d'affichage
</DialogTitle>
</div>
<div class="space-y-6">
<!-- Vue Grid -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Grille
</h4>
<div class="space-y-3">
<ToggleRow
label="Titre"
:value="options.grid.showTitle"
@update="setOption('grid', 'showTitle', $event)" />
<ToggleRow
label="Année de publication"
:value="options.grid.showYear"
@update="setOption('grid', 'showYear', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.grid.showAuthor"
@update="setOption('grid', 'showAuthor', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Overview -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Overview
</h4>
<div class="space-y-3">
<ToggleRow
label="Couverture"
:value="options.overview.showCover"
@update="setOption('overview', 'showCover', $event)" />
<ToggleRow
label="Statut"
:value="options.overview.showStatus"
@update="setOption('overview', 'showStatus', $event)" />
<ToggleRow
label="Description"
:value="options.overview.showDescription"
@update="setOption('overview', 'showDescription', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.overview.showAuthor"
@update="setOption('overview', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.overview.showYear"
@update="setOption('overview', 'showYear', $event)" />
</div>
</section>
<div class="border-t border-gray-200 dark:border-gray-700" />
<!-- Vue Table -->
<section>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Vue Table
</h4>
<div class="space-y-3">
<ToggleRow
label="Monitoring"
:value="options.table.showMonitoring"
@update="setOption('table', 'showMonitoring', $event)" />
<ToggleRow
label="Source préférée"
:value="options.table.showPreferredSource"
@update="setOption('table', 'showPreferredSource', $event)" />
<ToggleRow
label="Progression chapitres"
:value="options.table.showChapters"
@update="setOption('table', 'showChapters', $event)" />
<ToggleRow
label="Statut"
:value="options.table.showStatus"
@update="setOption('table', 'showStatus', $event)" />
<ToggleRow
label="Auteur(s)"
:value="options.table.showAuthor"
@update="setOption('table', 'showAuthor', $event)" />
<ToggleRow
label="Année de publication"
:value="options.table.showYear"
@update="setOption('table', 'showYear', $event)" />
</div>
</section>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="$emit('close')"
>
Fermer
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import ToggleRow from '../../../../shared/components/ui/ToggleRow.vue';
defineProps({
isOpen: {
type: Boolean,
required: true
},
options: {
type: Object,
required: true
}
});
const emit = defineEmits(['close', 'update']);
function setOption(view, key, value) {
emit('update', { view, key, value });
}
</script>

View File

@@ -35,12 +35,13 @@
</div>
</div>
<!-- Titre + année -->
<!-- Titre + méta -->
<RouterLink
:to="{ name: 'manga-details', params: { id: manga.id } }"
class="block p-2">
<h3 class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
<span v-if="manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
<h3 v-if="options.showTitle" class="text-xs font-medium text-gray-800 dark:text-gray-100 truncate">{{ manga.title }}</h3>
<span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">{{ manga.publicationYear }}</span>
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400 truncate block">{{ manga.authors[0] }}</span>
</RouterLink>
</div>
</template>
@@ -53,6 +54,10 @@ defineProps({
manga: {
type: Object,
required: true
},
options: {
type: Object,
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
}
});

View File

@@ -14,14 +14,14 @@
chapterId: chapter.id
}
}">
<template v-if="chapter.isVolumeGroup">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
Chapitres {{ chapter.volumeChaptersRange }}
</template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
</router-link>
<span v-else class="text-gray-500 dark:text-gray-400">
<template v-if="chapter.isVolumeGroup">
{{ chapter.volumeChapterCount > 1 ? 'Chapitres ' : 'Chapitre ' }}{{ chapter.volumeChaptersRange }}
<template v-if="chapter.isVolumeGroup && chapter.volumeChapterCount > 1">
Chapitres {{ chapter.volumeChaptersRange }}
</template>
<template v-else>{{ chapter.title || 'Sans titre' }}</template>
</span>

View File

@@ -4,6 +4,7 @@
v-for="manga in mangas"
:key="manga.id"
:manga="manga"
:options="options"
@edit="openEdit"
@sources="openSources"
@refresh="doRefresh" />
@@ -41,6 +42,10 @@ defineProps({
mangas: {
type: Array,
required: true
},
options: {
type: Object,
default: () => ({ showTitle: true, showYear: true, showAuthor: false })
}
});

View File

@@ -8,6 +8,7 @@
<!-- Cover -->
<img
v-if="options.showCover"
:src="manga.thumbnailUrl || manga.imageUrl || '/placeholder-cover.png'"
alt=""
class="h-36 w-24 object-cover flex-shrink-0 self-start"
@@ -23,13 +24,21 @@
{{ manga.title }}
</RouterLink>
<span
v-if="manga.status"
v-if="options.showStatus && manga.status"
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
:class="statusClass(manga.status)">
{{ manga.status }}
</span>
</div>
<p v-if="manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
<div class="flex items-center gap-3 mt-1 flex-wrap">
<span v-if="options.showAuthor && manga.authors?.length" class="text-xs text-gray-500 dark:text-gray-400">
{{ manga.authors.join(', ') }}
</span>
<span v-if="options.showYear && manga.publicationYear" class="text-xs text-gray-500 dark:text-gray-400">
{{ manga.publicationYear }}
</span>
</div>
<p v-if="options.showDescription && manga.description" class="text-sm text-gray-600 dark:text-gray-300 mt-2 line-clamp-4">
{{ manga.description }}
</p>
</div>
@@ -100,6 +109,10 @@ const props = defineProps({
mangas: {
type: Array,
required: true
},
options: {
type: Object,
default: () => ({ showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false })
}
});

View File

@@ -4,10 +4,13 @@
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="w-10 px-4 py-3"></th>
<th v-if="options.showMonitoring" class="w-10 px-4 py-3"></th>
<th class="py-3 pr-4 text-left font-medium">Titre</th>
<th class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
<th class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
<th v-if="options.showAuthor" class="py-3 pr-4 text-left font-medium w-36">Auteur</th>
<th v-if="options.showYear" class="py-3 pr-4 text-left font-medium w-20">Année</th>
<th v-if="options.showStatus" class="py-3 pr-4 text-left font-medium w-28">Statut</th>
<th v-if="options.showPreferredSource" class="py-3 pr-4 text-left font-medium w-44">Source préférée</th>
<th v-if="options.showChapters" class="py-3 pr-4 text-left font-medium w-44">Chapitres</th>
<th class="py-3 px-4 text-right font-medium w-28">Actions</th>
</tr>
</thead>
@@ -18,7 +21,7 @@
class="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<!-- Monitoring -->
<td class="px-4 py-3 text-center">
<td v-if="options.showMonitoring" class="px-4 py-3 text-center">
<button
:title="manga.monitored ? 'Monitoring actif — cliquer pour désactiver' : 'Monitoring inactif — cliquer pour activer'"
:class="manga.monitored
@@ -41,13 +44,34 @@
</RouterLink>
</td>
<!-- Auteur -->
<td v-if="options.showAuthor" class="py-3 pr-4">
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.authors?.join(', ') || '—' }}</span>
</td>
<!-- Année -->
<td v-if="options.showYear" class="py-3 pr-4">
<span class="text-sm text-gray-600 dark:text-gray-300">{{ manga.publicationYear || '—' }}</span>
</td>
<!-- Statut -->
<td v-if="options.showStatus" class="py-3 pr-4">
<span
v-if="manga.status"
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="statusClass(manga.status)">
{{ manga.status }}
</span>
<span v-else class="text-gray-400 dark:text-gray-600 text-xs"></span>
</td>
<!-- Source préférée -->
<td class="py-3 pr-4">
<td v-if="options.showPreferredSource" class="py-3 pr-4">
<MangaPreferredSourceCell :manga-id="manga.id" />
</td>
<!-- Chapitres barre de progression -->
<td class="py-3 pr-4">
<td v-if="options.showChapters" class="py-3 pr-4">
<div v-if="manga.chaptersTotal > 0">
<div class="flex items-center justify-between mb-1">
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
@@ -139,9 +163,19 @@ const props = defineProps({
mangas: {
type: Array,
required: true
},
options: {
type: Object,
default: () => ({ showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false })
}
});
function statusClass(status) {
if (status === 'ongoing') return 'text-blue-600 bg-blue-50 dark:bg-blue-900/20';
if (status === 'completed') return 'text-green-600 bg-green-50 dark:bg-green-900/20';
return 'text-gray-500 bg-gray-100 dark:bg-gray-700';
}
function progressPercent(manga) {
if (!manga.chaptersTotal) return 0;
return Math.round((manga.chaptersScraped / manga.chaptersTotal) * 100);

View File

@@ -3,12 +3,13 @@
<Toolbar :config="toolbarConfig" />
<div class="overflow-y-auto flex-1">
<div class="w-full">
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" />
<MangaGrid v-if="viewMode === 'grid'" :mangas="pagedItems" :options="prefs.displayOptions.grid" />
<MangaOverview
v-else-if="viewMode === 'list'"
:mangas="pagedItems"
:options="prefs.displayOptions.overview"
@manga-click="handleMangaClick" />
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" />
<MangaTable v-else-if="viewMode === 'table'" :mangas="pagedItems" :options="prefs.displayOptions.table" />
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
@@ -25,6 +26,12 @@
</div>
</div>
</div>
<HomeDisplaySettingsModal
:is-open="isDisplaySettingsOpen"
:options="prefs.displayOptions"
@close="isDisplaySettingsOpen = false"
@update="({ view, key, value }) => prefs.setDisplayOption(view, key, value)" />
</div>
</template>
@@ -44,6 +51,7 @@ import { useUserPreferencesStore } from '../../../../domain/setting/application/
import Pagination from '../../../../shared/components/ui/Pagination.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
import HomeDisplaySettingsModal from '../components/HomeDisplaySettingsModal.vue';
import MangaGrid from '../components/MangaGrid.vue';
import MangaOverview from '../components/MangaOverview.vue';
import MangaTable from '../components/MangaTable.vue';
@@ -61,6 +69,7 @@ import MangaTable from '../components/MangaTable.vue';
const viewMode = ref(prefs.defaultView);
const currentPage = ref(1);
const isDisplaySettingsOpen = ref(false);
onMounted(() => {
mangaStore.loadCollection();
@@ -71,7 +80,12 @@ import MangaTable from '../components/MangaTable.vue';
};
const sortedCollection = computed(() => {
const items = [...(collection.value?.items || [])];
let items = [...(collection.value?.items || [])];
if (prefs.filterBy === 'completed') {
items = items.filter(m => m.status?.toLowerCase() === 'completed');
} else if (prefs.filterBy === 'ongoing') {
items = items.filter(m => m.status?.toLowerCase() === 'ongoing');
}
if (prefs.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title));
} else if (prefs.sortBy === 'addedAt') {
@@ -91,7 +105,7 @@ import MangaTable from '../components/MangaTable.vue';
currentPage.value = 1;
});
const toolbarConfig = {
const toolbarConfig = computed(() => ({
leftSection: [
{
icon: ArrowPathIcon,
@@ -103,15 +117,15 @@ import MangaTable from '../components/MangaTable.vue';
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
],
rightSection: [
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
{ icon: Cog6ToothIcon, label: 'Options', type: 'button', onClick: () => { isDisplaySettingsOpen.value = true; } },
{
icon: EyeIcon,
type: 'dropdown',
label: 'View',
items: [
{ label: 'Overview', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
{ label: 'Grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
{ label: 'Table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
{ label: 'Overview', isSelected: prefs.defaultView === 'list', onClick: () => { viewMode.value = 'list'; prefs.setDefaultView('list'); } },
{ label: 'Grid', isSelected: prefs.defaultView === 'grid', onClick: () => { viewMode.value = 'grid'; prefs.setDefaultView('grid'); } },
{ label: 'Table', isSelected: prefs.defaultView === 'table', onClick: () => { viewMode.value = 'table'; prefs.setDefaultView('table'); } }
]
},
{
@@ -119,9 +133,9 @@ import MangaTable from '../components/MangaTable.vue';
type: 'dropdown',
label: 'Sort',
items: [
{ label: 'Title', onClick: () => prefs.setSortBy('title') },
{ label: "Date d'ajout", onClick: () => prefs.setSortBy('addedAt') },
{ label: 'Progression', onClick: () => prefs.setSortBy('progress') }
{ label: 'Title', isSelected: prefs.sortBy === 'title', onClick: () => prefs.setSortBy('title') },
{ label: "Date d'ajout", isSelected: prefs.sortBy === 'addedAt', onClick: () => prefs.setSortBy('addedAt') },
{ label: 'Progression', isSelected: prefs.sortBy === 'progress', onClick: () => prefs.setSortBy('progress') }
]
},
{
@@ -129,11 +143,11 @@ import MangaTable from '../components/MangaTable.vue';
type: 'dropdown',
label: 'Filter',
items: [
{ label: 'All', onClick: () => {} },
{ label: 'Completed', onClick: () => {} },
{ label: 'In Progress', onClick: () => {} }
{ label: 'All', isSelected: prefs.filterBy === 'all', onClick: () => prefs.setFilterBy('all') },
{ label: 'Completed', isSelected: prefs.filterBy === 'completed', onClick: () => prefs.setFilterBy('completed') },
{ label: 'In Progress', isSelected: prefs.filterBy === 'ongoing', onClick: () => prefs.setFilterBy('ongoing') }
]
}
]
};
}));
</script>

View File

@@ -8,6 +8,12 @@ const defaultState = {
defaultView: 'grid',
itemsPerPage: 20,
sortBy: 'title',
filterBy: 'all',
displayOptions: {
grid: { showTitle: true, showYear: true, showAuthor: false },
overview: { showCover: true, showStatus: true, showDescription: true, showAuthor: false, showYear: false },
table: { showMonitoring: true, showPreferredSource: true, showChapters: true, showStatus: false, showAuthor: false, showYear: false }
},
readingDirection: 'ltr',
readingMode: 'scroll',
autoFullscreen: false,
@@ -88,6 +94,16 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
this.persist();
},
setFilterBy(filter) {
this.filterBy = filter;
this.persist();
},
setDisplayOption(view, key, value) {
this.displayOptions[view][key] = value;
this.persist();
},
setReadingDirection(direction) {
this.readingDirection = direction;
this.persist();
@@ -127,6 +143,8 @@ export const useUserPreferencesStore = defineStore('userPreferences', {
defaultView: this.defaultView,
itemsPerPage: this.itemsPerPage,
sortBy: this.sortBy,
filterBy: this.filterBy,
displayOptions: this.displayOptions,
readingDirection: this.readingDirection,
readingMode: this.readingMode,
autoFullscreen: this.autoFullscreen,

View File

@@ -242,8 +242,17 @@ watch(() => props.source, (newSource) => {
}
}, { immediate: true });
const buildPayload = (formData) => {
const data = { ...formData };
const raw = data.testChapterNumber;
data.testChapterNumber = (raw === '' || raw === null || raw === undefined)
? null
: parseFloat(raw);
return data;
};
const handleSubmit = () => {
emit('submit', { ...form.value });
emit('submit', buildPayload(form.value));
};
defineExpose({ submitForm: handleSubmit });
@@ -252,7 +261,7 @@ const testConfiguration = async () => {
testing.value = true;
try {
await emit('test', {
configuration: { ...form.value },
configuration: buildPayload(form.value),
testData: {
mangaSlug: form.value.testSlug,
chapterNumber: form.value.testChapterNumber,

View File

@@ -0,0 +1,37 @@
<template>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ label }}</span>
<button
type="button"
role="switch"
:aria-checked="value"
:class="[
value ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600',
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2'
]"
@click="$emit('update', !value)"
>
<span
:class="[
value ? 'translate-x-4' : 'translate-x-0',
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
]"
/>
</button>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true
},
value: {
type: Boolean,
required: true
}
});
defineEmits(['update']);
</script>

View File

@@ -8,7 +8,7 @@
<slot name="center" />
<!-- Right section -->
<ToolbarSection :items="config.rightSection" />
<ToolbarSection :items="config.rightSection" align="right" />
</div>
</div>
</template>

View File

@@ -13,7 +13,10 @@
</div>
<MenuItems
class="absolute left-0 mt-2 w-max origin-top-left rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
:class="[
'absolute mt-2 w-max rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10',
align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
]">
<div class="px-1 py-1">
<MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled">
<button
@@ -50,6 +53,11 @@ import ToolbarLabel from './ToolbarLabel.vue';
type: Boolean,
default: false
},
align: {
type: String,
default: 'left',
validator: v => ['left', 'right'].includes(v)
},
items: {
type: Array,
required: true,

View File

@@ -13,7 +13,8 @@
:icon="item.icon"
:label="item.label"
:active="item.active"
:items="item.items" />
:items="item.items"
:align="align" />
<Divider v-else-if="item.type === 'divider'" />
<span
v-else-if="item.type === 'label'"
@@ -43,6 +44,10 @@
(item.type === 'dropdown' && Array.isArray(item.items)))
);
}
},
align: {
type: String,
default: 'left'
}
});
</script>

View File

@@ -3,57 +3,50 @@
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.3.1",
"php": ">=8.4.0",
"ext-ctype": "*",
"ext-curl": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-zip": "*",
"api-platform/core": "^3.2",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.11",
"api-platform/core": "^4.0",
"doctrine/dbal": "^4",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.17",
"doctrine/orm": "^3.0",
"guzzlehttp/guzzle": "^7.8",
"intervention/image": "^3.7",
"nelmio/cors-bundle": "^2.4",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.25",
"ramsey/uuid": "^4.7",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.0.*",
"symfony/console": "7.0.*",
"symfony/css-selector": "7.0.*",
"symfony/doctrine-messenger": "7.0.*",
"symfony/dotenv": "7.0.*",
"symfony/expression-language": "7.0.*",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/css-selector": "8.0.*",
"symfony/doctrine-messenger": "8.0.*",
"symfony/dotenv": "8.0.*",
"symfony/expression-language": "8.0.*",
"symfony/flex": "^2",
"symfony/form": "7.0.*",
"symfony/framework-bundle": "7.0.*",
"symfony/http-client": "7.0.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.0.*",
"symfony/mime": "7.0.*",
"symfony/monolog-bundle": "^3.10",
"symfony/framework-bundle": "8.0.*",
"symfony/http-client": "8.0.*",
"symfony/mercure-bundle": "^0.4",
"symfony/messenger": "8.0.*",
"symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0",
"symfony/panther": "^2.1",
"symfony/property-access": "7.0.*",
"symfony/property-info": "7.0.*",
"symfony/runtime": "7.0.*",
"symfony/scheduler": "7.0.*",
"symfony/security-bundle": "7.0.*",
"symfony/serializer": "7.0.*",
"symfony/stimulus-bundle": "^2.17",
"symfony/twig-bundle": "7.0.*",
"symfony/ux-live-component": "^2.17",
"symfony/ux-react": "^2.23",
"symfony/ux-turbo": "^2.18",
"symfony/validator": "7.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/scheduler": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/webpack-encore-bundle": "^2.1",
"symfony/yaml": "7.0.*",
"twig/extra-bundle": "^2.12|^3.0",
"symfony/yaml": "8.0.*",
"twig/twig": "^2.12|^3.0",
"vich/uploader-bundle": "^2.7"
},
@@ -103,7 +96,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.0.*",
"require": "8.0.*",
"docker": true
}
},
@@ -111,18 +104,18 @@
"dama/doctrine-test-bundle": "^8.2",
"dbrekelmans/bdi": "^1.3",
"deployer/deployer": "^7.5",
"doctrine/doctrine-fixtures-bundle": "^3.5",
"doctrine/doctrine-fixtures-bundle": "^4.0",
"friendsofphp/php-cs-fixer": "^3.48",
"mtdowling/jmespath.php": "^2.7",
"phparkitect/phparkitect": "^0.3.33",
"phpmd/phpmd": "^2.15",
"phparkitect/phparkitect": "^0.8",
"phpmd/phpmd": "3.x-dev",
"phpunit/phpunit": "^10.5",
"symfony/browser-kit": "7.0.*",
"symfony/browser-kit": "8.0.*",
"symfony/maker-bundle": "^1.52",
"symfony/phpunit-bridge": "^7.0",
"symfony/stopwatch": "7.0.*",
"symfony/web-profiler-bundle": "7.0.*",
"symfony/phpunit-bridge": "^8.0",
"symfony/stopwatch": "8.0.*",
"symfony/web-profiler-bundle": "8.0.*",
"zenstruck/browser": "^1.8",
"zenstruck/foundry": "^1.36"
"zenstruck/foundry": "^2.0"
}
}

4313
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,7 @@ return [
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Symfony\UX\React\ReactBundle::class => ['all' => true],
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
];

View File

@@ -23,8 +23,6 @@ api_platform:
extra_properties:
standard_put: true
rfc_7807_compliant_errors: true
event_listeners_backward_compatibility_layer: false
keep_legacy_inflector: false
mapping:
paths:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'

View File

@@ -3,7 +3,6 @@ doctrine:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
use_savepoints: true
profiling_collect_backtrace: '%kernel.debug%'
# IMPORTANT: You MUST configure your server version,
@@ -11,9 +10,6 @@ doctrine:
#server_version: '16'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
@@ -40,15 +36,12 @@ when@test:
dbal:
connections:
default:
use_savepoints: true
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -1,5 +0,0 @@
twig_component:
anonymous_template_directory: 'components/'
defaults:
# Namespace & directory for components
App\Twig\Components\: 'components/'

2001
config/reference.php Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View File

@@ -1,5 +0,0 @@
live_component:
resource: '@LiveComponentBundle/config/routes.php'
prefix: '/_components'
# adjust prefix to add localization to your components
#prefix: '/{_locale}/_components'

View File

@@ -1,8 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler

View File

@@ -1,4 +1,5 @@
<?php
namespace Deployer;
require 'recipe/symfony.php';
@@ -36,12 +37,13 @@ task('deploy:vendors', function () {
$releaseDir = get('release_path');
$previousDir = get('previous_release');
if ($previousDir !== null) {
if (null !== $previousDir) {
$lockUnchanged = test("diff -q $previousDir/composer.lock $releaseDir/composer.lock > /dev/null 2>&1");
$vendorPopulated = test("[ -d $releaseDir/vendor/composer ]");
if ($lockUnchanged && $vendorPopulated) {
writeln('<info>deploy:vendors skipped — composer.lock unchanged</info>');
return;
}
}
@@ -67,7 +69,7 @@ task('webpack_encore:build', function () {
$previousDir = get('previous_release'); // null au 1er déploiement
// --- COUCHE 1 : skip total si aucun fichier front-end n'a changé ---
if ($previousDir !== null) {
if (null !== $previousDir) {
$watchList = ['assets', 'templates', 'package.json', 'package-lock.json',
'webpack.config.js', 'postcss.config.js', 'tailwind.config.js'];
@@ -81,13 +83,14 @@ task('webpack_encore:build', function () {
if ($hasPreviousBuild && test("($diffChecks)")) {
run("cp -al $previousDir/public/build $releaseDir/public/build");
writeln('<info>webpack_encore:build skipped — no front-end files changed</info>');
return;
}
}
// --- COUCHE 2 : skip npm install si package-lock.json inchangé ---
$needsNpmInstall = true;
if ($previousDir !== null) {
if (null !== $previousDir) {
$lockUnchanged = test("diff -q $previousDir/package-lock.json $releaseDir/package-lock.json > /dev/null 2>&1");
$nmPopulated = test("[ -d $sharedNodeModules/.bin ]");
if ($lockUnchanged && $nmPopulated) {
@@ -113,14 +116,13 @@ task('webpack_encore:build', function () {
sh -c '$installCmd'");
});
// Restart Docker containers (entrypoint gère les migrations automatiquement)
// Le cache:clear est fait APRÈS le restart : Docker résout le bind mount au démarrage
// du container, pas dynamiquement. Avant restart, docker exec voit encore l'ancienne release.
// Restart Docker containers (entrypoint gère migrations + cache:warmup automatiquement)
// Le cache est regénéré par l'entrypoint AVANT que FrankenPHP ne démarre,
// ce qui évite la race condition entre FrankenPHP et un docker exec concurrent.
desc('Restart Docker containers');
task('docker:restart', function () {
run('docker restart mangarr-worker-commands mangarr-worker-events mangarr-worker-scheduler');
run('docker restart mangarr');
run('docker exec mangarr php bin/console cache:clear --env=prod');
});
// Pas de PHP sur l'hôte : désactiver les tâches Symfony qui en ont besoin

View File

@@ -31,7 +31,9 @@
mercure {
# Transport to use (default to Bolt)
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
transport bolt {
url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
}
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key

View File

@@ -53,6 +53,14 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
fi
fi
# Vider le cache prod stale et le regénérer AVANT le démarrage de FrankenPHP.
# Sans ça, FrankenPHP et le deploy script compilent le container DI en parallèle
# → fichiers partiellement écrits → crash au démarrage des workers.
if [ "$APP_ENV" = "prod" ]; then
rm -rf var/cache/prod
php bin/console cache:warmup --env=prod
fi
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
fi

View File

@@ -1,4 +1,4 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
num 2
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260326165659 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrate manga.genres column from PHP-serialized array to JSON';
}
public function preUp(Schema $schema): void
{
// Convert existing PHP-serialized data to JSON before changing the column type
$rows = $this->connection->fetchAllAssociative('SELECT id, genres FROM manga WHERE genres IS NOT NULL');
foreach ($rows as $row) {
$raw = $row['genres'];
// Skip if already valid JSON
json_decode($raw);
if (json_last_error() === JSON_ERROR_NONE) {
continue;
}
// Unserialize PHP format and re-encode as JSON
$value = @unserialize($raw);
if ($value === false && $raw !== 'b:0;') {
$value = [];
}
$this->connection->executeStatement(
'UPDATE manga SET genres = :json WHERE id = :id',
['json' => json_encode($value), 'id' => $row['id']]
);
}
}
public function up(Schema $schema): void
{
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'\'');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'\'');
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.started_at IS \'\'');
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'\'');
$this->addSql('ALTER TABLE manga ALTER genres TYPE JSON USING genres::json');
$this->addSql('COMMENT ON COLUMN manga.genres IS \'\'');
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'\'');
$this->addSql('COMMENT ON COLUMN source.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'\'');
$this->addSql('DROP INDEX idx_75ea56e0e3bd61ce');
$this->addSql('DROP INDEX idx_75ea56e0fb7336f0');
$this->addSql('DROP INDEX idx_75ea56e016ba31db');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE messenger_messages ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'\'');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('COMMENT ON COLUMN api_token.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN content_source.health_last_tested_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE manga ALTER genres TYPE TEXT');
$this->addSql('COMMENT ON COLUMN manga.genres IS \'(DC2Type:array)\'');
$this->addSql('COMMENT ON COLUMN manga.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga.last_monitoring_check IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN manga_preferred_sources.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750');
$this->addSql('ALTER TABLE messenger_messages ALTER id SET DEFAULT nextval(\'messenger_messages_id_seq\'::regclass)');
$this->addSql('ALTER TABLE messenger_messages ALTER id DROP IDENTITY');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE INDEX idx_75ea56e0e3bd61ce ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX idx_75ea56e0fb7336f0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX idx_75ea56e016ba31db ON messenger_messages (delivered_at)');
$this->addSql('COMMENT ON COLUMN source.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN source.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
}

3506
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,26 +2,15 @@
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@babel/preset-react": "^7.26.3",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.2.0",
"@hotwired/stimulus": "^3.0.0",
"@hotwired/turbo": "^7.1.1 || ^8.0",
"@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets",
"@symfony/ux-react": "file:vendor/symfony/ux-react/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^4.0.0",
"@vue/compiler-sfc": "^3.5.13",
"core-js": "^3.23.0",
"daisyui": "^4.4.2",
"pinia": "^3.0.1",
"react": "^18.0",
"react-dom": "^18.0",
"regenerator-runtime": "^0.13.9",
"sass": "^1.59.3",
"sass-loader": "^13.2.0",
"stimulus-use": "^0.52.2",
"vue": "^3.5.13",
"vue-loader": "^17.4.2",
"vue-router": "^4.5.0",
@@ -41,18 +30,12 @@
"@fortawesome/fontawesome-free": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@tanstack/vue-query": "^5.71.0",
"alpinejs": "^3.13.3",
"autoprefixer": "^10.4.14",
"axios": "^1.7.9",
"bootstrap": "^5.3.3",
"postcss-loader": "^7.1.0",
"puppeteer": "^22.10.0",
"react-router-dom": "^7.1.5",
"sortablejs": "^1.15.2",
"tailwindcss": "^3.2.7",
"vue-i18n": "^11.3.0",
"vuedraggable": "^2.24.3"
"vue-i18n": "^11.3.0"
}
}

View File

@@ -29,7 +29,7 @@ return static function (Config $config): void {
// Dépendances externes autorisées
$externalDependencies = [
'Symfony\Component\Messenger',
'Ramsey\Uuid'
'Ramsey\Uuid',
];
// Règle pour le namespace cohérent
@@ -72,7 +72,7 @@ return static function (Config $config): void {
// Interdiction explicite pour l'Application d'accéder à l'Infrastructure
$rules[] = Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces("App\Domain\\$domain\Application"))
->should(new NotDependsOnTheseNamespaces("App\Domain\\$domain\Infrastructure"))
->should(new NotDependsOnTheseNamespaces(["App\Domain\\$domain\Infrastructure"]))
->because("la couche Application de $domain ne doit jamais dépendre de l'Infrastructure, même au sein de son propre domaine");
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:monitoring:run',
description: 'Déclenche immédiatement la vérification des mangas monitorés (sans attendre le scheduler)',
)]
class RunMonitoringCommand extends Command
{
public function __construct(
private readonly MessageBusInterface $commandBus,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Déclenchement du monitoring des mangas...');
$this->commandBus->dispatch(new CheckMonitoredMangas());
$output->writeln('<info>Vérification lancée. Les nouveaux chapitres détectés seront scrappés via le worker commands.</info>');
return Command::SUCCESS;
}
}

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class SendTestNotificationCommand extends Command
{
public function __construct(
private readonly NotificationInterface $notification
private readonly NotificationInterface $notification,
) {
parent::__construct();
}
@@ -38,6 +38,7 @@ class SendTestNotificationCommand extends Command
$allowed = ['info', 'success', 'error', 'warning'];
if (!in_array($type, $allowed, true)) {
$output->writeln(sprintf('<error>Type invalide "%s". Valeurs acceptées : %s</error>', $type, implode(', ', $allowed)));
return Command::FAILURE;
}

View File

@@ -4,7 +4,6 @@ namespace App\Controller;
use ApiPlatform\Api\IriConverterInterface;
use App\Entity\User;
use Exception;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -13,11 +12,11 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
public function login(IriConverterInterface $iriConverter, #[CurrentUser] ?User $user = null): Response
{
if (!$user) {
return $this->json([
'error' => 'Invalid credentials'
'error' => 'Invalid credentials',
], 401);
}
@@ -27,11 +26,11 @@ class SecurityController extends AbstractController
}
/**
* @throws Exception
* @throws \Exception
*/
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
public function logout(): void
{
throw new Exception('This method can be blank.');
throw new \Exception('This method can be blank.');
}
}

View File

@@ -7,7 +7,7 @@ final readonly class ConvertFileCommand
public function __construct(
public string $filePath,
public string $originalFilename,
public int $fileSize
public int $fileSize,
) {
}
}

View File

@@ -13,7 +13,7 @@ final readonly class ConvertFileCommandHandler
private const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
public function __construct(
private ConversionServiceInterface $conversionService
private ConversionServiceInterface $conversionService,
) {
}

View File

@@ -10,7 +10,7 @@ final readonly class ConversionResponse
public string $convertedFilePath,
public string $outputFilename,
public int $originalFileSize,
public int $convertedFileSize
public int $convertedFileSize,
) {
}

View File

@@ -2,9 +2,7 @@
namespace App\Domain\Conversion\Domain\Exception;
use RuntimeException;
class ConversionException extends RuntimeException
class ConversionException extends \RuntimeException
{
public static function fileNotFound(string $filePath): self
{

View File

@@ -7,7 +7,7 @@ final readonly class ConversionRequest
public function __construct(
private string $filePath,
private string $originalFilename,
private int $fileSize
private int $fileSize,
) {
}
@@ -29,6 +29,7 @@ final readonly class ConversionRequest
public function getOutputFilename(): string
{
$pathInfo = pathinfo($this->originalFilename, PATHINFO_FILENAME);
return $pathInfo.'.cbz';
}
}

View File

@@ -8,7 +8,7 @@ final readonly class ConversionResult
private string $convertedFilePath,
private string $outputFilename,
private int $originalFileSize,
private int $convertedFileSize
private int $convertedFileSize,
) {
}

View File

@@ -5,18 +5,16 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Controller;
use App\Domain\Conversion\Application\Command\ConvertFileCommand;
use App\Domain\Conversion\Application\CommandHandler\ConvertFileCommandHandler;
use App\Domain\Conversion\Domain\Exception\ConversionException;
use App\Domain\Conversion\Infrastructure\ApiPlatform\Resource\ConvertFileResource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
#[AsController]
final class ConvertFileController extends AbstractController
{
public function __construct(
private readonly ConvertFileCommandHandler $commandHandler
private readonly ConvertFileCommandHandler $commandHandler,
) {
}
@@ -25,7 +23,7 @@ final class ConvertFileController extends AbstractController
$uploadedFile = $request->files->get('file');
if (!$uploadedFile) {
return $this->json([
['propertyPath' => 'file', 'message' => 'Please upload a file']
['propertyPath' => 'file', 'message' => 'Please upload a file'],
], 422);
}
@@ -58,7 +56,6 @@ final class ConvertFileController extends AbstractController
'Content-Disposition' => sprintf('attachment; filename=%s', $response->outputFilename),
]
);
} catch (ConversionException $e) {
return $this->json(['error' => $e->getMessage()], 400);
}
@@ -72,8 +69,9 @@ final class ConvertFileController extends AbstractController
if (!$uploadedFile->isValid()) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
'message' => 'The uploaded file is not valid: '.$uploadedFile->getErrorMessage(),
];
return $errors;
}
@@ -82,7 +80,7 @@ final class ConvertFileController extends AbstractController
if ($uploadedFile->getSize() > $maxSize) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'The uploaded file is too large. Allowed size is 150MB.'
'message' => 'The uploaded file is too large. Allowed size is 150MB.',
];
}
@@ -93,7 +91,7 @@ final class ConvertFileController extends AbstractController
if (!in_array($extension, $allowedExtensions)) {
$errors[] = [
'propertyPath' => 'file',
'message' => 'Please upload a valid CBR or CBZ file'
'message' => 'Please upload a valid CBR or CBZ file',
];
}

View File

@@ -4,10 +4,10 @@ namespace App\Domain\Conversion\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;
use App\Domain\Conversion\Infrastructure\ApiPlatform\Controller\ConvertFileController;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Conversion',
@@ -16,11 +16,11 @@ use Symfony\Component\Validator\Constraints as Assert;
uriTemplate: '/conversions/convert',
controller: ConvertFileController::class,
deserialize: false,
openapiContext: [
'summary' => 'Convert comic book file to CBZ',
'description' => 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
'requestBody' => [
'content' => [
openapi: new Operation(
summary: 'Convert comic book file to CBZ',
description: 'Converts a CBR or CBZ file to CBZ format and returns the converted file for download',
requestBody: new RequestBody(
content: new \ArrayObject([
'multipart/form-data' => [
'schema' => [
'type' => 'object',
@@ -29,28 +29,28 @@ use Symfony\Component\Validator\Constraints as Assert;
'file' => [
'type' => 'string',
'format' => 'binary',
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)'
]
]
]
]
]
'description' => 'Comic book file to convert (CBR, CBZ, max 150MB)',
],
'responses' => [
],
],
],
])
),
responses: [
'200' => [
'description' => 'File converted successfully',
'content' => [
'application/x-cbz' => [
'schema' => [
'type' => 'string',
'format' => 'binary'
]
]
]
]
]
'format' => 'binary',
],
],
],
],
]
)
),
]
)]
class ConvertFileResource

View File

@@ -56,7 +56,7 @@ final class ConversionService implements ConversionServiceInterface
$process->run();
if (!$process->isSuccessful()) {
throw new \RuntimeException("Extraction failed: " . $process->getErrorOutput());
throw new \RuntimeException('Extraction failed: '.$process->getErrorOutput());
}
}
@@ -64,8 +64,8 @@ final class ConversionService implements ConversionServiceInterface
$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");
if (true !== $zip->open($cbzPath, \ZipArchive::CREATE)) {
throw new \RuntimeException('Cannot create ZIP file');
}
$files = new \RecursiveIteratorIterator(

View File

@@ -7,7 +7,7 @@ readonly class ChapterEditData
public function __construct(
public string $id,
public ?string $title = null,
public ?int $volume = null
public ?int $volume = null,
) {
}
}

View File

@@ -2,12 +2,10 @@
namespace App\Domain\Manga\Application\Command;
use DateTimeImmutable;
readonly class CheckMonitoredMangas
{
public function __construct(
public ?DateTimeImmutable $since = null
public ?\DateTimeImmutable $since = null,
) {
}
}

View File

@@ -14,7 +14,7 @@ readonly class CreateManga
public string $status,
public ?string $externalId,
public ?string $imageUrl,
public ?float $rating
public ?float $rating,
) {
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Command;
readonly class CreateMangaFromMangadex
{
public function __construct(
public string $externalId
public string $externalId,
) {
}
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteCbz implements CommandInterface
{
public function __construct(
public string $chapterId
public string $chapterId,
) {
}
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteChapter implements CommandInterface
{
public function __construct(
public string $chapterId
public string $chapterId,
) {
}
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteManga implements CommandInterface
{
public function __construct(
public string $mangaId
public string $mangaId,
) {
}
}

View File

@@ -13,7 +13,7 @@ readonly class EditManga
public ?array $genres = null,
public ?string $status = null,
public ?float $rating = null,
public ?array $alternativeSlugs = null
public ?array $alternativeSlugs = null,
) {
}
}

View File

@@ -8,7 +8,7 @@ readonly class EditMultipleChapters
* @param array<ChapterEditData> $chapters
*/
public function __construct(
public array $chapters
public array $chapters,
) {
}
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class FetchMangaChapters
{
public function __construct(
public MangaId $mangaId
public MangaId $mangaId,
) {
}
}

View File

@@ -7,7 +7,7 @@ readonly class ImportChapter
public function __construct(
public string $mangaId,
public float $chapterNumber,
public string $fileBinary
public string $fileBinary,
) {
}
}

View File

@@ -7,7 +7,7 @@ readonly class ImportVolume
public function __construct(
public string $mangaId,
public int $volumeNumber,
public string $fileBinary
public string $fileBinary,
) {
}
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class RefreshMangaChapters
{
public function __construct(
public MangaId $mangaId
public MangaId $mangaId,
) {
}
}

View File

@@ -8,7 +8,7 @@ readonly class ToggleMangaMonitoring
{
public function __construct(
public MangaId $mangaId,
public bool $enabled
public bool $enabled,
) {
}
}

View File

@@ -6,14 +6,13 @@ use App\Domain\Manga\Application\Command\CheckMonitoredMangas;
use App\Domain\Manga\Application\Command\RefreshMangaChapters;
use App\Domain\Manga\Application\Query\MonitoringCriteria;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class CheckMonitoredMangasHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private MessageBusInterface $commandBus
private MessageBusInterface $commandBus,
) {
}
@@ -21,7 +20,7 @@ readonly class CheckMonitoredMangasHandler
{
$criteria = new MonitoringCriteria(
enabled: true,
lastCheckBefore: $command->since ?? new DateTimeImmutable('-1 hour')
lastCheckBefore: new \DateTimeImmutable('-2 hours')
);
$monitoredMangas = $this->mangaRepository->findByMonitoringCriteria($criteria);

View File

@@ -3,7 +3,6 @@
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CreateMangaFromMangadex;
use App\Domain\Manga\Application\Response\CreateMangaResponse;
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface;
@@ -19,7 +18,7 @@ readonly class CreateMangaFromMangadexHandler
private MangaProviderInterface $mangaProvider,
private MangaRepositoryInterface $mangaRepository,
private ImageProcessorInterface $imageProcessor,
private EventDispatcherInterface $eventDispatcher
private EventDispatcherInterface $eventDispatcher,
) {
}
@@ -27,7 +26,7 @@ readonly class CreateMangaFromMangadexHandler
{
$manga = $this->mangaProvider->findByExternalId(new ExternalId($command->externalId));
if ($manga === null) {
if (null === $manga) {
throw new MangaNotFoundException('Manga not found on Mangadex');
}

View File

@@ -20,7 +20,7 @@ readonly class CreateMangaHandler
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ImageProcessorInterface $imageProcessor,
private MessageBusInterface $messageBus
private MessageBusInterface $messageBus,
) {
}

View File

@@ -5,8 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\DeleteCbz;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
use App\Domain\Shared\Domain\Contract\CommandInterface;
@@ -14,7 +14,7 @@ readonly class DeleteCbzHandler implements CommandHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private FileServiceInterface $fileService
private FileServiceInterface $fileService,
) {
}

View File

@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteChapterHandler implements CommandHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -11,7 +11,7 @@ use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteMangaHandler implements CommandHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
readonly class EditMangaHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}
@@ -23,35 +23,35 @@ readonly class EditMangaHandler
}
// Update only provided fields (partial update)
if ($command->title !== null) {
if (null !== $command->title) {
$manga->updateTitle(new MangaTitle($command->title));
}
if ($command->description !== null) {
if (null !== $command->description) {
$manga->updateDescription($command->description);
}
if ($command->author !== null) {
if (null !== $command->author) {
$manga->updateAuthor($command->author);
}
if ($command->publicationYear !== null) {
if (null !== $command->publicationYear) {
$manga->updatePublicationYear($command->publicationYear);
}
if ($command->genres !== null) {
if (null !== $command->genres) {
$manga->updateGenres($command->genres);
}
if ($command->status !== null) {
if (null !== $command->status) {
$manga->updateStatus($command->status);
}
if ($command->rating !== null) {
if (null !== $command->rating) {
$manga->setRating($command->rating);
}
if ($command->alternativeSlugs !== null) {
if (null !== $command->alternativeSlugs) {
$manga->updateAlternativeSlugs($command->alternativeSlugs);
}

View File

@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
readonly class EditMultipleChaptersHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}
@@ -24,11 +24,11 @@ readonly class EditMultipleChaptersHandler
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
if ($chapterData->title !== null) {
if (null !== $chapterData->title) {
$manga->updateChapterTitle($chapter, $chapterData->title);
}
if ($chapterData->volume !== null) {
if (null !== $chapterData->volume) {
$manga->updateChapterVolume($chapter, $chapterData->volume);
}

View File

@@ -12,7 +12,7 @@ readonly class FetchMangaChaptersHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
) {
}
@@ -20,12 +20,12 @@ readonly class FetchMangaChaptersHandler
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
if (null === $manga) {
throw new MangaNotFoundException();
}
if ($manga->getExternalId() === null) {
throw new MangadexApiException("Manga has no external_id");
if (null === $manga->getExternalId()) {
throw new MangadexApiException('Manga has no external_id');
}
// Synchronisation initiale (pas d'événements)

View File

@@ -4,15 +4,15 @@ namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
readonly class ImportChapterHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ImageStorageInterface $imageStorage
private ImageStorageInterface $imageStorage,
) {
}
@@ -55,6 +55,6 @@ readonly class ImportChapterHandler
{
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
return strpos($fileBinary, $zipMagicNumber) === 0;
return 0 === strpos($fileBinary, $zipMagicNumber);
}
}

View File

@@ -11,7 +11,7 @@ readonly class ImportVolumeHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ImageStorageInterface $imageStorage
private ImageStorageInterface $imageStorage,
) {
}
@@ -35,9 +35,7 @@ readonly class ImportVolumeHandler
);
if (empty($chapters)) {
throw new \InvalidArgumentException(
"No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}"
);
throw new \InvalidArgumentException("No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}");
}
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
@@ -56,6 +54,6 @@ readonly class ImportVolumeHandler
{
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
return strpos($fileBinary, $zipMagicNumber) === 0;
return 0 === strpos($fileBinary, $zipMagicNumber);
}
}

View File

@@ -7,7 +7,6 @@ use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use DateTimeImmutable;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class RefreshMangaChaptersHandler
@@ -15,7 +14,7 @@ readonly class RefreshMangaChaptersHandler
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterSynchronizationServiceInterface $chapterSynchronizationService,
private MessageBusInterface $eventBus
private MessageBusInterface $eventBus,
) {
}
@@ -23,22 +22,30 @@ readonly class RefreshMangaChaptersHandler
{
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
if ($manga === null) {
if (null === $manga) {
throw new \RuntimeException('Manga not found');
}
// Synchronisation + récupération des nouveaux IDs
$newChapterIds = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Synchronisation + récupération des numéros de nouveaux chapitres
$newChapterNumbers = $this->chapterSynchronizationService->synchronizeChapters($manga);
// Mise à jour de la date de monitoring
$manga->updateLastMonitoringCheck(new DateTimeImmutable());
$manga->updateLastMonitoringCheck(new \DateTimeImmutable());
$this->mangaRepository->save($manga);
// Événement de scraping pour chaque nouveau chapitre
foreach ($newChapterIds as $chapterId) {
// On retrouve l'ID réel (PK integer) après save() car le chapitre n'a
// son identifiant définitif qu'une fois persisté en base.
foreach ($newChapterNumbers as $chapterNumber) {
$saved = $this->mangaRepository->findChapterByMangaIdAndNumber(
$manga->getId()->getValue(),
$chapterNumber
);
if ($saved) {
$this->eventBus->dispatch(
new ChapterReadyForScraping(new ChapterId($chapterId))
new ChapterReadyForScraping(new ChapterId($saved->getId()))
);
}
}
}
}

View File

@@ -9,7 +9,7 @@ use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
readonly class ToggleMangaMonitoringHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
readonly class MangaCreatedEventListener
{
public function __construct(
private FetchMangaChaptersHandler $fetchMangaChaptersHandler
private FetchMangaChaptersHandler $fetchMangaChaptersHandler,
) {
}

View File

@@ -23,7 +23,7 @@ readonly class VolumeImportedEventListener
}
$chapters = $this->mangaRepository->findChaptersByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
if ($chapters === []) {
if ([] === $chapters) {
return;
}

View File

@@ -7,7 +7,7 @@ use App\Domain\Shared\Domain\Contract\QueryInterface;
readonly class DownloadCbz implements QueryInterface
{
public function __construct(
public string $chapterId
public string $chapterId,
) {
}
}

View File

@@ -8,7 +8,7 @@ readonly class DownloadVolume implements QueryInterface
{
public function __construct(
public string $mangaId,
public int $volume
public int $volume,
) {
}
}

View File

@@ -7,7 +7,7 @@ namespace App\Domain\Manga\Application\Query;
readonly class FindMangaMatchByFilename
{
public function __construct(
public string $filename
public string $filename,
) {
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
readonly class GetMangaById
{
public function __construct(
public string $id
public string $id,
) {
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
readonly class GetMangaBySlug
{
public function __construct(
public string $slug
public string $slug,
) {
}
}

View File

@@ -8,7 +8,7 @@ readonly class GetMangaChapters
public string $mangaId,
public ?int $page = 1,
public ?int $limit = 20,
public ?string $sortOrder = 'desc'
public ?string $sortOrder = 'desc',
) {
}
}

View File

@@ -8,7 +8,7 @@ readonly class GetMangaList
public ?int $page = 1,
public ?int $limit = 20,
public ?string $sortBy = 'title',
public ?string $sortOrder = 'asc'
public ?string $sortOrder = 'asc',
) {
}
}

View File

@@ -2,13 +2,11 @@
namespace App\Domain\Manga\Application\Query;
use DateTimeImmutable;
readonly class MonitoringCriteria
{
public function __construct(
public bool $enabled,
public ?DateTimeImmutable $lastCheckBefore = null
public ?\DateTimeImmutable $lastCheckBefore = null,
) {
}
}

View File

@@ -7,7 +7,7 @@ readonly class SearchLocalManga
public function __construct(
public string $query,
public int $page = 1,
public int $limit = 20
public int $limit = 20,
) {
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Domain\Manga\Application\Query;
readonly class SearchManga
{
public function __construct(
public string $title
public string $title,
) {
}
}

View File

@@ -13,7 +13,7 @@ readonly class DiscoverMangaHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private MangaProviderInterface $mangaProvider
private MangaProviderInterface $mangaProvider,
) {
}
@@ -41,7 +41,7 @@ readonly class DiscoverMangaHandler
$recommendations = array_values(array_filter(
$collection->getItems(),
fn (Manga $m) => $m->getExternalId() === null
fn (Manga $m) => null === $m->getExternalId()
|| !in_array($m->getExternalId()->getValue(), $ownedExternalIds, true)
));

View File

@@ -7,8 +7,9 @@ use App\Domain\Manga\Application\Response\DownloadResponse;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Service\FileServiceInterface;
use App\Domain\Manga\Domain\Exception\CbzFileNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotAvailableException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
@@ -17,7 +18,7 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private FileServiceInterface $fileService
private FileServiceInterface $fileService,
) {
}
@@ -35,8 +36,19 @@ readonly class DownloadCbzHandler implements QueryHandlerInterface
throw new ChapterNotAvailableException($query->chapterId);
}
$manga = $this->mangaRepository->findById($chapter->getMangaId()->getValue());
if (!$manga) {
throw new MangaNotFoundException($chapter->getMangaId()->getValue());
}
$pagesDirectory = $chapter->getPagesDirectory();
$filename = basename($pagesDirectory);
$number = $chapter->getNumber();
$formattedNumber = fmod($number, 1.0) === 0.0
? sprintf('%03d', (int) $number)
: rtrim(rtrim(sprintf('%06.2f', $number), '0'), '.');
$filename = sprintf('%s - Ch.%s', $manga->getTitle()->getValue(), $formattedNumber);
try {
$httpResponse = $this->fileService->downloadCbz($pagesDirectory, $filename);

View File

@@ -16,7 +16,7 @@ readonly class DownloadVolumeHandler implements QueryHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private FileServiceInterface $fileService
private FileServiceInterface $fileService,
) {
}

View File

@@ -15,7 +15,7 @@ readonly class FindMangaMatchByFilenameHandler
{
public function __construct(
private FilenameAnalyzerInterface $filenameAnalyzer,
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}
@@ -70,7 +70,7 @@ readonly class FindMangaMatchByFilenameHandler
/**
* Calcule un score de correspondance entre le manga et le titre recherché
* Score plus élevé = meilleure correspondance
* Score plus élevé = meilleure correspondance.
*/
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
{
@@ -97,12 +97,12 @@ readonly class FindMangaMatchByFilenameHandler
}
// Le titre du manga contient le terme recherché
if (stripos($mangaTitle, $searchedTitle) !== false) {
if (false !== stripos($mangaTitle, $searchedTitle)) {
$score += 50;
}
// Le terme recherché contient le titre du manga
if (stripos($searchedTitle, $mangaTitle) !== false) {
if (false !== stripos($searchedTitle, $mangaTitle)) {
$score += 40;
}

View File

@@ -10,7 +10,7 @@ use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
readonly class GetMangaByIdHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
readonly class GetMangaBySlugHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -12,7 +12,7 @@ use App\Domain\Manga\Domain\Model\Chapter;
readonly class GetMangaChaptersHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}
@@ -30,7 +30,7 @@ readonly class GetMangaChaptersHandler
$grouped = $this->groupChapters($allChapters);
if ($query->sortOrder === 'desc') {
if ('desc' === $query->sortOrder) {
usort($grouped, fn (ChapterResponse $a, ChapterResponse $b) => $b->number <=> $a->number);
}
@@ -58,7 +58,7 @@ readonly class GetMangaChaptersHandler
$pagesDir = $chapter->getPagesDirectory();
$volume = $chapter->getVolume();
if ($pagesDir !== null && $volume !== null) {
if (null !== $pagesDir && null !== $volume) {
if ($pagesDir === $currentPagesDir && $volume === $currentVolume) {
$currentGroup[] = $chapter;
} else {
@@ -103,7 +103,9 @@ readonly class GetMangaChaptersHandler
$min = min($numbers);
$max = max($numbers);
$fmt = fn (float $n) => $n == (int) $n ? (string) (int) $n : (string) $n;
$fmt = fn (float $n) => $n == (int) $n
? str_pad((string) (int) $n, 2, '0', STR_PAD_LEFT)
: (string) $n;
$range = count($group) > 1 ? $fmt($min).'-'.$fmt($max) : $fmt($min);
return new ChapterResponse(

View File

@@ -3,13 +3,13 @@
namespace App\Domain\Manga\Application\QueryHandler;
use App\Domain\Manga\Application\Query\GetMangaList;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Application\Response\MangaListResponse;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
readonly class GetMangaListHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
private MangaRepositoryInterface $mangaRepository,
) {
}

View File

@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\Manga;
readonly class SearchLocalMangaHandler
{
public function __construct(
private MangaRepositoryInterface $repository
private MangaRepositoryInterface $repository,
) {
}

View File

@@ -11,7 +11,7 @@ use App\Domain\Manga\Domain\Model\Manga;
readonly class SearchMangaHandler
{
public function __construct(
private MangaProviderInterface $mangaProvider
private MangaProviderInterface $mangaProvider,
) {
}
@@ -19,7 +19,6 @@ readonly class SearchMangaHandler
{
$mangaCollection = $this->mangaProvider->search($query->title);
return new MangaSearchResponse(
array_map(
fn (Manga $manga, int $index) => new MangaSearchItem(

View File

@@ -8,7 +8,7 @@ readonly class ChapterListResponse
public array $chapters,
public int $total,
public int $page,
public int $limit
public int $limit,
) {
}

View File

@@ -8,7 +8,7 @@ use Symfony\Component\HttpFoundation\Response;
readonly class DownloadResponse implements ResponseInterface
{
public function __construct(
public Response $httpResponse
public Response $httpResponse,
) {
}
}

View File

@@ -9,7 +9,7 @@ readonly class MangaListResponse
public int $total,
public int $page,
public int $limit,
public array $chapterCounts = []
public array $chapterCounts = [],
) {
}

View File

@@ -14,7 +14,7 @@ readonly class MangaMatchItem
public ?string $thumbnailUrl,
public int $matchScore,
public ?float $chapterNumber = null,
public ?float $volumeNumber = null
public ?float $volumeNumber = null,
) {
}
}

View File

@@ -13,7 +13,7 @@ readonly class MangaMatchResponse
public array $matches,
public ?float $chapterNumber,
public ?float $volumeNumber,
public array $possibleTitles
public array $possibleTitles,
) {
}

Some files were not shown because too many files have changed in this diff Show More