Compare commits
5 Commits
4da9742f7f
...
c268b2c312
| Author | SHA1 | Date | |
|---|---|---|---|
| c268b2c312 | |||
| c060e7b95e | |||
|
|
2e3abb76c3 | ||
| b40892b924 | |||
|
|
74f033f5d1 |
41
TASK.md
41
TASK.md
@@ -75,6 +75,47 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Perf] Reader — Lazy-loading des pages (InfiniteReader)
|
||||||
|
|
||||||
|
**Problème :** `readerStore.js` charge toutes les pages avec `itemsPerPage=9999`. `InfiniteReader.vue` monte tous les composants `ReaderPage` simultanément dans le DOM. Sur un chapitre de 200 pages, cela représente 200 composants actifs et autant d'images pré-chargées.
|
||||||
|
|
||||||
|
- [ ] Implémenter un `IntersectionObserver` sur les wrappers de page pour ne charger les images qu'au moment où elles entrent dans le viewport (`loading="lazy"` ou src conditionnel)
|
||||||
|
- [ ] Limiter le nombre de composants montés simultanément (virtualisation ou windowing) : ne rendre que les pages proches de la page courante (ex. fenêtre de ±3 pages)
|
||||||
|
- [ ] Adapter `readerStore.js` : remplacer `itemsPerPage=9999` par la vraie pagination côté API si la virtualisation le justifie, sinon conserver le fetch unique mais différer le rendu
|
||||||
|
- [ ] Vérifier que le mode `single` n'est pas impacté (il affiche déjà une seule page)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — N+1 requêtes SQL dans `getChapterContext()`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` émet 5 requêtes SQL pour un seul chargement : la requête principale + 2 doublons dans `getPreviousChapterId()` / `getNextChapterId()` (chacune re-fetche le chapitre courant) + les 2 requêtes de navigation.
|
||||||
|
|
||||||
|
- [ ] Refactorer `getPreviousChapterId()` et `getNextChapterId()` pour accepter l'entité `ChapterEntity` déjà chargée en paramètre (au lieu de re-fetcher par ID)
|
||||||
|
- [ ] Appeler ces méthodes depuis `getChapterContext()` en passant l'entité déjà disponible
|
||||||
|
- [ ] Résultat attendu : 3 requêtes maximum (1 pour le chapitre courant + 1 prev + 1 next), idéalement 1 seule avec une requête SQL combinée
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — Division par zéro dans `ChapterPagesResponse::getTotalPages()`
|
||||||
|
|
||||||
|
**Problème :** `ceil($totalItems / $itemsPerPage)` crashe si `itemsPerPage = 0`. Le test existant documente le bug avec un TODO et assert un HTTP 500 au lieu de corriger.
|
||||||
|
|
||||||
|
- [ ] Ajouter une validation dans `ChapterPagesProvider` : rejeter la requête avec HTTP 400 si `itemsPerPage <= 0`
|
||||||
|
- [ ] Corriger le test `GetChapterPagesTest` pour vérifier HTTP 400 (et non 500)
|
||||||
|
- [ ] Supprimer le commentaire TODO du test une fois corrigé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [Bug] Reader — `totalPages` toujours égal à 0 dans `ChapterContext`
|
||||||
|
|
||||||
|
**Problème :** `LegacyChapterRepository::getChapterContext()` hardcode `totalPages: 0`. La méthode `getTotalPagesForChapter()` existe mais n'est jamais appelée depuis `GetChapterContextHandler`.
|
||||||
|
|
||||||
|
- [ ] Appeler `getTotalPagesForChapter()` dans `getChapterContext()` (ou dans le handler) pour calculer le vrai nombre de pages
|
||||||
|
- [ ] Vérifier que la valeur est correctement sérialisée dans la réponse API Platform (`ChapterContextResponse`)
|
||||||
|
- [ ] Adapter les tests existants qui pourraient asserter `totalPages: 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
## [Style] Page conversion CBR → CBZ — Simplification UI + notifications toast
|
||||||
|
|
||||||
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
**Objectif :** Revoir le style de la page de conversion CBR → CBZ pour le simplifier, et remplacer le message statique "Conversion réussie" par les notifications toast de l'application.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
:pages="store.pages"
|
:pages="store.pages"
|
||||||
:zoom="store.zoom"
|
:zoom="store.zoom"
|
||||||
:double-page-mode="store.effectiveDoublePageMode"
|
:double-page-mode="store.effectiveDoublePageMode"
|
||||||
|
:initial-page="store.currentPage"
|
||||||
@page-visible="store.handlePageVisible"
|
@page-visible="store.handlePageVisible"
|
||||||
ref="infiniteReaderRef" />
|
ref="infiniteReaderRef" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="infinite-reader" ref="containerRef">
|
<div class="infinite-reader" ref="containerRef">
|
||||||
<div v-for="(page, index) in pages" :key="index"
|
<div v-for="(page, index) in pages" :key="index" class="page-wrapper" :data-page-index="index">
|
||||||
class="page-wrapper" :data-page-index="index">
|
<ReaderPage
|
||||||
|
v-if="isPageInWindow(index) && page?.url"
|
||||||
<!-- Pas d'URL : spinner de chargement -->
|
|
||||||
<div v-if="!page?.url" class="loading">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hors de la zone de rendu : placeholder dimensionné -->
|
|
||||||
<div v-else-if="!mountedPageIndices.has(index)"
|
|
||||||
class="page-placeholder"
|
|
||||||
:style="{ height: getPlaceholderHeight(page) + 'px' }" />
|
|
||||||
|
|
||||||
<!-- Dans la zone : composant complet -->
|
|
||||||
<ReaderPage v-else
|
|
||||||
:page-data="page"
|
:page-data="page"
|
||||||
:page-number="index + 1"
|
:page-number="index + 1"
|
||||||
:zoom="zoom"
|
:zoom="zoom"
|
||||||
:double-page-mode="doublePageMode"
|
:double-page-mode="doublePageMode"
|
||||||
:window-width="windowWidth"
|
loading="eager"
|
||||||
loading="lazy" />
|
/>
|
||||||
|
<div v-else class="page-placeholder" :style="getPlaceholderStyle(page)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton flottant pour revenir en haut -->
|
<!-- Bouton flottant pour revenir en haut -->
|
||||||
@@ -49,10 +38,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
import { useHeaderStore } from '../../../../shared/stores/headerStore';
|
||||||
import ReaderPage from './ReaderPage.vue';
|
import ReaderPage from './ReaderPage.vue';
|
||||||
|
|
||||||
|
const WINDOW_SIZE = 3;
|
||||||
|
const currentVisibleIndex = ref(0); // initialisé via prop initialPage dans onMounted
|
||||||
|
|
||||||
|
const isPageInWindow = (index) => Math.abs(index - currentVisibleIndex.value) <= WINDOW_SIZE;
|
||||||
|
|
||||||
|
const getPlaceholderStyle = (page) => {
|
||||||
|
if (page?.dimensions?.width && page?.dimensions?.height) {
|
||||||
|
const maxW = windowWidth.value < 1200
|
||||||
|
? windowWidth.value * 0.95
|
||||||
|
: 1200;
|
||||||
|
return {
|
||||||
|
aspectRatio: `${page.dimensions.width} / ${page.dimensions.height}`,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${Math.min(page.dimensions.width, maxW)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { height: '800px', width: '100%' };
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: {
|
pages: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -65,6 +73,10 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
doublePageMode: {
|
doublePageMode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
initialPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +85,6 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
const headerStore = useHeaderStore();
|
const headerStore = useHeaderStore();
|
||||||
const containerRef = ref(null);
|
const containerRef = ref(null);
|
||||||
const observer = ref(null);
|
const observer = ref(null);
|
||||||
const visibilityObserver = ref(null);
|
|
||||||
const mountedPageIndices = reactive(new Set());
|
|
||||||
const windowWidth = ref(window.innerWidth);
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
// État unique pour tous les boutons flottants avec timer de 3 secondes
|
||||||
@@ -86,54 +96,34 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
let scrollDirection = 'down';
|
let scrollDirection = 'down';
|
||||||
|
|
||||||
const observeIntersection = entries => {
|
const observeIntersection = entries => {
|
||||||
entries.forEach(entry => {
|
const intersectingIndices = entries
|
||||||
if (entry.isIntersecting) {
|
.filter(e => e.isIntersecting)
|
||||||
const pageIndex = parseInt(entry.target.getAttribute('data-page-index'));
|
.map(e => parseInt(e.target.getAttribute('data-page-index')));
|
||||||
emit('pageVisible', pageIndex);
|
|
||||||
}
|
if (intersectingIndices.length > 0) {
|
||||||
});
|
const minIdx = Math.min(...intersectingIndices);
|
||||||
|
currentVisibleIndex.value = minIdx;
|
||||||
|
emit('pageVisible', minIdx);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calcul de la hauteur du placeholder — miroir exact du maxWidth de ReaderPage
|
const setupIntersectionObserver = () => {
|
||||||
const getPlaceholderHeight = (page) => {
|
if (observer.value) {
|
||||||
const dims = page?.dimensions;
|
observer.value.disconnect();
|
||||||
if (!dims?.width || !dims?.height) return 800;
|
}
|
||||||
const displayWidth = windowWidth.value < 1200
|
|
||||||
? Math.min(dims.width, windowWidth.value * 0.95)
|
|
||||||
: Math.min(dims.width, 1200);
|
|
||||||
return Math.round((dims.height / dims.width) * displayWidth);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupObservers = () => {
|
|
||||||
observer.value?.disconnect();
|
|
||||||
visibilityObserver.value?.disconnect();
|
|
||||||
|
|
||||||
observer.value = new IntersectionObserver(observeIntersection, {
|
observer.value = new IntersectionObserver(observeIntersection, {
|
||||||
root: null,
|
root: null,
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
visibilityObserver.value = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const idx = parseInt(entry.target.getAttribute('data-page-index'));
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
mountedPageIndices.add(idx);
|
|
||||||
} else {
|
|
||||||
mountedPageIndices.delete(idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ root: null, rootMargin: '1000px 0px', threshold: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const els = containerRef.value?.querySelectorAll('.page-wrapper');
|
const pageElements = containerRef.value?.querySelectorAll('.page-wrapper');
|
||||||
els?.forEach((el, i) => {
|
if (pageElements) {
|
||||||
el.setAttribute('data-page-index', i);
|
pageElements.forEach(element => {
|
||||||
observer.value.observe(el);
|
observer.value.observe(element);
|
||||||
visibilityObserver.value.observe(el);
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,16 +207,21 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
|
|
||||||
// Fonction pour revenir en haut de la page
|
// Fonction pour revenir en haut de la page
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
|
console.log('scrollToTop appelée'); // Debug
|
||||||
|
|
||||||
// Réinitialiser le timer lors du clic
|
// Réinitialiser le timer lors du clic
|
||||||
resetButtonsTimer();
|
resetButtonsTimer();
|
||||||
|
|
||||||
// Stratégie 1: Scroll sur le conteneur direct
|
// Stratégie 1: Scroll sur le conteneur direct
|
||||||
if (containerRef.value) {
|
if (containerRef.value) {
|
||||||
|
console.log('containerRef trouvé, scrollTop actuel:', containerRef.value.scrollTop); // Debug
|
||||||
|
|
||||||
if (containerRef.value.scrollTop > 0) {
|
if (containerRef.value.scrollTop > 0) {
|
||||||
containerRef.value.scrollTo({
|
containerRef.value.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
|
console.log('Scroll sur containerRef effectué'); // Debug
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +231,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
while (currentElement) {
|
while (currentElement) {
|
||||||
const styles = window.getComputedStyle(currentElement);
|
const styles = window.getComputedStyle(currentElement);
|
||||||
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
if (styles.overflowY === 'auto' || styles.overflowY === 'scroll' || currentElement.scrollTop > 0) {
|
||||||
|
console.log('Conteneur avec scroll trouvé:', currentElement.className, 'scrollTop:', currentElement.scrollTop); // Debug
|
||||||
currentElement.scrollTo({
|
currentElement.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
@@ -246,6 +242,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stratégie 3: Scroll sur la fenêtre entière
|
// Stratégie 3: Scroll sur la fenêtre entière
|
||||||
|
console.log('Scroll sur window, scrollY actuel:', window.scrollY); // Debug
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
@@ -261,8 +258,7 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
watch(
|
watch(
|
||||||
() => props.pages,
|
() => props.pages,
|
||||||
() => {
|
() => {
|
||||||
mountedPageIndices.clear();
|
setupIntersectionObserver();
|
||||||
setupObservers();
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
@@ -281,7 +277,8 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setupObservers();
|
currentVisibleIndex.value = props.initialPage;
|
||||||
|
setupIntersectionObserver();
|
||||||
|
|
||||||
// Activer l'auto-hide du header si la largeur < 1200px
|
// Activer l'auto-hide du header si la largeur < 1200px
|
||||||
if (windowWidth.value < 1200) {
|
if (windowWidth.value < 1200) {
|
||||||
@@ -301,8 +298,9 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
observer.value?.disconnect();
|
if (observer.value) {
|
||||||
visibilityObserver.value?.disconnect();
|
observer.value.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
// Désactiver l'auto-hide du header en quittant
|
// Désactiver l'auto-hide du header en quittant
|
||||||
headerStore.disableAutoHide();
|
headerStore.disableAutoHide();
|
||||||
@@ -337,34 +335,25 @@ import ReaderPage from './ReaderPage.vue';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-placeholder {
|
.page-placeholder {
|
||||||
@apply w-full;
|
@apply flex justify-center;
|
||||||
max-width: 1200px;
|
background: transparent;
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
@apply flex items-center justify-center min-h-[400px];
|
@apply text-red-500 text-xl bg-red-500/10 rounded-lg flex items-center justify-center min-h-[400px];
|
||||||
/* Largeur adaptative selon la taille d'écran */
|
width: 95vw;
|
||||||
width: 95vw; /* Mobile : 95% de la largeur */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen sm {
|
@screen sm {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 80vw; /* Tablette : 80% de la largeur */
|
width: 80vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen lg {
|
@screen lg {
|
||||||
.loading,
|
|
||||||
.error {
|
.error {
|
||||||
width: 70vw; /* Desktop : 70% de la largeur */
|
width: 70vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
|
||||||
@apply text-red-500 text-xl bg-red-500/10 rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image rotated"
|
class="page-image rotated"
|
||||||
:style="doublePageRotatedStyle"
|
:style="doublePageRotatedStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
<div class="rotation-hint">
|
<div class="rotation-hint">
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
:alt="`Page ${pageNumber} (Double page)`"
|
:alt="`Page ${pageNumber} (Double page)`"
|
||||||
class="page-image scrollable"
|
class="page-image scrollable"
|
||||||
:style="doublePageScrollStyle"
|
:style="doublePageScrollStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
:alt="`Page ${pageNumber}`"
|
:alt="`Page ${pageNumber}`"
|
||||||
class="page-image"
|
class="page-image"
|
||||||
:style="imageStyle"
|
:style="imageStyle"
|
||||||
|
:loading="loading"
|
||||||
@load="handleImageLoad"
|
@load="handleImageLoad"
|
||||||
ref="imageRef" />
|
ref="imageRef" />
|
||||||
</div>
|
</div>
|
||||||
@@ -79,9 +82,9 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
default: 'rotate', // 'rotate', 'scroll', 'normal'
|
||||||
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
validator: (value) => ['rotate', 'scroll', 'normal'].includes(value)
|
||||||
},
|
},
|
||||||
windowWidth: {
|
loading: {
|
||||||
type: Number,
|
type: String,
|
||||||
default: null
|
default: 'lazy',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,11 +103,8 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
const scrollContainerRef = ref(null);
|
const scrollContainerRef = ref(null);
|
||||||
const naturalWidth = ref(0);
|
const naturalWidth = ref(0);
|
||||||
const naturalHeight = ref(0);
|
const naturalHeight = ref(0);
|
||||||
const localWindowWidth = ref(window.innerWidth);
|
const windowWidth = ref(window.innerWidth);
|
||||||
const effectiveWindowWidth = computed(() =>
|
const isMobile = computed(() => windowWidth.value < 768);
|
||||||
props.windowWidth !== null ? props.windowWidth : localWindowWidth.value
|
|
||||||
);
|
|
||||||
const isMobile = computed(() => effectiveWindowWidth.value < 768);
|
|
||||||
const imageLoaded = ref(false);
|
const imageLoaded = ref(false);
|
||||||
|
|
||||||
const imageSource = computed(() => {
|
const imageSource = computed(() => {
|
||||||
@@ -123,13 +123,17 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
// Utiliser d'abord les dimensions de l'API si disponibles
|
// Utiliser d'abord les dimensions de l'API si disponibles
|
||||||
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
if (props.pageData?.dimensions?.width && props.pageData?.dimensions?.height) {
|
||||||
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
const ratio = props.pageData.dimensions.width / props.pageData.dimensions.height;
|
||||||
return ratio > threshold;
|
const isDouble = ratio > threshold;
|
||||||
|
console.log(`API Dimensions - Page ${props.pageNumber}: ${props.pageData.dimensions.width}x${props.pageData.dimensions.height}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||||
|
return isDouble;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
// Fallback sur les dimensions naturelles de l'image (seulement si l'image est chargée)
|
||||||
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
if (imageLoaded.value && naturalWidth.value && naturalHeight.value) {
|
||||||
const ratio = naturalWidth.value / naturalHeight.value;
|
const ratio = naturalWidth.value / naturalHeight.value;
|
||||||
return ratio > threshold;
|
const isDouble = ratio > threshold;
|
||||||
|
console.log(`Natural Dimensions - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}, ratio: ${ratio.toFixed(2)}, isDouble: ${isDouble}`);
|
||||||
|
return isDouble;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -140,6 +144,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
naturalWidth.value = imageRef.value.naturalWidth;
|
naturalWidth.value = imageRef.value.naturalWidth;
|
||||||
naturalHeight.value = imageRef.value.naturalHeight;
|
naturalHeight.value = imageRef.value.naturalHeight;
|
||||||
imageLoaded.value = true;
|
imageLoaded.value = true;
|
||||||
|
console.log(`Image loaded - Page ${props.pageNumber}: ${naturalWidth.value}x${naturalHeight.value}`);
|
||||||
|
|
||||||
// Positionner le scroll à droite si c'est le mode scroll
|
// Positionner le scroll à droite si c'est le mode scroll
|
||||||
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
if (props.doublePageMode === 'scroll' && scrollContainerRef.value) {
|
||||||
@@ -190,7 +195,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
|
|
||||||
if (!width || !height) return null;
|
if (!width || !height) return null;
|
||||||
|
|
||||||
const availableWidth = effectiveWindowWidth.value;
|
const availableWidth = windowWidth.value;
|
||||||
|
|
||||||
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
// Si la largeur disponible est < 1200px : utiliser 95% de la largeur
|
||||||
if (availableWidth < 1200) {
|
if (availableWidth < 1200) {
|
||||||
@@ -239,7 +244,7 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
if (!width || !height) return {};
|
if (!width || !height) return {};
|
||||||
|
|
||||||
// En mode rotation : maximiser l'utilisation de l'espace
|
// En mode rotation : maximiser l'utilisation de l'espace
|
||||||
const availableWidth = effectiveWindowWidth.value;
|
const availableWidth = windowWidth.value;
|
||||||
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
const availableHeight = window.innerHeight - 100; // Laisser un peu d'espace pour les contrôles
|
||||||
|
|
||||||
// Après rotation, la largeur originale devient la hauteur affichée
|
// Après rotation, la largeur originale devient la hauteur affichée
|
||||||
@@ -289,18 +294,20 @@ import { useReaderStore } from '../../application/store/readerStore';
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let ownResizeHandler = null;
|
// Gestion du redimensionnement de la fenêtre
|
||||||
|
const handleResize = () => {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.windowWidth === null) {
|
if (imageRef.value && imageRef.value.complete) {
|
||||||
ownResizeHandler = () => { localWindowWidth.value = window.innerWidth; };
|
handleImageLoad();
|
||||||
window.addEventListener('resize', ownResizeHandler, { passive: true });
|
|
||||||
}
|
}
|
||||||
if (imageRef.value?.complete) handleImageLoad();
|
window.addEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (ownResizeHandler) window.removeEventListener('resize', ownResizeHandler);
|
window.removeEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: messenger.message_handler, bus: command.bus }
|
- { name: messenger.message_handler, bus: command.bus }
|
||||||
|
|
||||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||||
alias: App\Domain\Scraping\Infrastructure\Service\LocalImageStorage
|
alias: App\Domain\Shared\Infrastructure\Service\ImageStorageManager
|
||||||
|
|
||||||
App\Domain\Scraping\Infrastructure\Service\LocalImageStorage:
|
App\Domain\Shared\Infrastructure\Service\ImageStorageManager:
|
||||||
arguments:
|
arguments:
|
||||||
$storagePath: '%kernel.project_dir%/public/images'
|
$storagePath: '%kernel.project_dir%/public/images'
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface:
|
App\Domain\Shared\Domain\Contract\ImageStorageInterface:
|
||||||
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
|
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage'
|
||||||
public: true
|
public: true
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use App\Domain\Manga\Application\Command\ImportChapter;
|
|||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
|
||||||
readonly class ImportChapterHandler
|
readonly class ImportChapterHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MangaPathManagerInterface $pathManager
|
private ImageStorageInterface $imageStorage
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,11 +39,15 @@ readonly class ImportChapterHandler
|
|||||||
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
|
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save the CBZ file to storage using the path manager
|
// 4. Extract CBZ into individual images storage
|
||||||
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter);
|
$pagesDirectory = $this->imageStorage->extractFromCbz(
|
||||||
|
$existingChapter->getId(),
|
||||||
|
$command->fileBinary
|
||||||
|
);
|
||||||
|
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||||
|
|
||||||
// 5. Update existing chapter with new path through the aggregate
|
// 5. Update existing chapter with new path through the aggregate
|
||||||
$manga->updateChapterPages($existingChapter, $cbzPath, $existingChapter->getPageCount());
|
$manga->updateChapterPages($existingChapter, $pagesDirectory, $pageCount);
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,21 +57,4 @@ readonly class ImportChapterHandler
|
|||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, \App\Domain\Manga\Domain\Model\Chapter $chapter): string
|
|
||||||
{
|
|
||||||
$volumeNumber = $chapter->getVolume() ?? 0;
|
|
||||||
$cbzPath = $this->pathManager->buildChapterCbzPath(
|
|
||||||
$manga->getTitle()->getValue(),
|
|
||||||
(string)$manga->getPublicationYear(),
|
|
||||||
$volumeNumber,
|
|
||||||
(string)$command->chapterNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
|
||||||
throw new \RuntimeException('Failed to save CBZ file');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cbzPath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
|
||||||
readonly class ImportVolumeHandler
|
readonly class ImportVolumeHandler
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaRepositoryInterface $mangaRepository,
|
private MangaRepositoryInterface $mangaRepository,
|
||||||
private MangaPathManagerInterface $pathManager
|
private ImageStorageInterface $imageStorage
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +40,14 @@ readonly class ImportVolumeHandler
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save the CBZ file to storage using the path manager
|
// 4. Extract CBZ into individual images storage (shared directory for all volume chapters)
|
||||||
$cbzPath = $this->saveCbzFile($command, $manga);
|
$volumeDirectoryId = sprintf('volume_%s_%d', $command->mangaId, $command->volumeNumber);
|
||||||
|
$pagesDirectory = $this->imageStorage->extractFromCbz($volumeDirectoryId, $command->fileBinary);
|
||||||
|
$pageCount = $this->imageStorage->countCbzImages($command->fileBinary);
|
||||||
|
|
||||||
// 5. Update all chapters with the volume path through the aggregate
|
// 5. Update all chapters with the volume path through the aggregate
|
||||||
foreach ($chapters as $chapter) {
|
foreach ($chapters as $chapter) {
|
||||||
$manga->updateChapterPages($chapter, $cbzPath, $chapter->getPageCount());
|
$manga->updateChapterPages($chapter, $pagesDirectory, $pageCount);
|
||||||
}
|
}
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
}
|
}
|
||||||
@@ -56,19 +58,4 @@ readonly class ImportVolumeHandler
|
|||||||
|
|
||||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
|
|
||||||
{
|
|
||||||
$cbzPath = $this->pathManager->buildVolumeCbzPath(
|
|
||||||
$manga->getTitle()->getValue(),
|
|
||||||
(string)$manga->getPublicationYear(),
|
|
||||||
$command->volumeNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
|
||||||
throw new \RuntimeException('Failed to save CBZ file');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cbzPath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
|||||||
$pages[] = new Page(
|
$pages[] = new Page(
|
||||||
basename($files[$i]),
|
basename($files[$i]),
|
||||||
new PageNumber($i + 1),
|
new PageNumber($i + 1),
|
||||||
sprintf('/images/pages/%s/%s', $chapterId->getValue(), basename($files[$i])),
|
sprintf('/images/pages/%s/%s', basename($pagesDirectory), basename($files[$i])),
|
||||||
$imageSize[0],
|
$imageSize[0],
|
||||||
$imageSize[1]
|
$imageSize[1]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use App\Domain\Scraping\Application\Command\ScrapeChapter;
|
|||||||
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||||
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
use App\Domain\Shared\Domain\Event\ChapterScraped;
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Domain\Contract\Service;
|
|
||||||
|
|
||||||
interface ImageStorageInterface
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Copies images to permanent storage. Returns the pagesDirectory path.
|
|
||||||
*
|
|
||||||
* @param string $chapterId The chapter UUID used as directory name
|
|
||||||
* @param string[] $localImagePaths Paths to the locally downloaded image files
|
|
||||||
*
|
|
||||||
* @return string Absolute path to the directory where images were stored
|
|
||||||
*/
|
|
||||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domain\Scraping\Infrastructure\Service;
|
|
||||||
|
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
|
||||||
|
|
||||||
readonly class LocalImageStorage implements ImageStorageInterface
|
|
||||||
{
|
|
||||||
public function __construct(private string $storagePath)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
|
||||||
{
|
|
||||||
$targetDir = $this->storagePath . '/pages/' . $chapterId;
|
|
||||||
|
|
||||||
if (!is_dir($targetDir)) {
|
|
||||||
mkdir($targetDir, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($localImagePaths);
|
|
||||||
|
|
||||||
foreach ($localImagePaths as $index => $localPath) {
|
|
||||||
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
|
||||||
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
|
||||||
copy($localPath, $targetFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $targetDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
28
src/Domain/Shared/Domain/Contract/ImageStorageInterface.php
Normal file
28
src/Domain/Shared/Domain/Contract/ImageStorageInterface.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Domain\Contract;
|
||||||
|
|
||||||
|
interface ImageStorageInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Store images from local file paths into the individual images storage.
|
||||||
|
* Used by the scraping flow.
|
||||||
|
*
|
||||||
|
* @param string[] $localImagePaths
|
||||||
|
* @return string The directory path where images are stored (pagesDirectory)
|
||||||
|
*/
|
||||||
|
public function storeChapterImages(string $targetId, array $localImagePaths): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract images from a CBZ binary into the individual images storage.
|
||||||
|
* Used by the import flow.
|
||||||
|
*
|
||||||
|
* @return string The directory path where images are stored (pagesDirectory)
|
||||||
|
*/
|
||||||
|
public function extractFromCbz(string $targetId, string $cbzBinary): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count images in a CBZ binary.
|
||||||
|
*/
|
||||||
|
public function countCbzImages(string $cbzBinary): int;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Shared\Infrastructure\Service;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class ImageStorageManager implements ImageStorageInterface
|
||||||
|
{
|
||||||
|
public function __construct(private string $storagePath)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeChapterImages(string $targetId, array $localImagePaths): string
|
||||||
|
{
|
||||||
|
$targetDir = $this->storagePath . '/pages/' . $targetId;
|
||||||
|
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
mkdir($targetDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($localImagePaths);
|
||||||
|
|
||||||
|
foreach ($localImagePaths as $index => $localPath) {
|
||||||
|
$extension = pathinfo($localPath, PATHINFO_EXTENSION) ?: 'jpg';
|
||||||
|
$targetFile = sprintf('%s/%03d.%s', $targetDir, $index + 1, $extension);
|
||||||
|
copy($localPath, $targetFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $targetDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractFromCbz(string $targetId, string $cbzBinary): string
|
||||||
|
{
|
||||||
|
$targetDir = $this->storagePath . '/pages/' . $targetId;
|
||||||
|
|
||||||
|
if (!is_dir($targetDir)) {
|
||||||
|
mkdir($targetDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||||
|
file_put_contents($tmpFile, $cbzBinary);
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($tmpFile) !== true) {
|
||||||
|
unlink($tmpFile);
|
||||||
|
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageEntries = [];
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
|
||||||
|
$imageEntries[] = ['index' => $i, 'name' => $name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($imageEntries, fn ($a, $b) => strcmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
foreach ($imageEntries as $seq => $entry) {
|
||||||
|
$extension = strtolower(pathinfo($entry['name'], PATHINFO_EXTENSION)) ?: 'jpg';
|
||||||
|
$targetFile = sprintf('%s/%03d.%s', $targetDir, $seq + 1, $extension);
|
||||||
|
$content = $zip->getFromIndex($entry['index']);
|
||||||
|
file_put_contents($targetFile, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
unlink($tmpFile);
|
||||||
|
|
||||||
|
return $targetDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countCbzImages(string $cbzBinary): int
|
||||||
|
{
|
||||||
|
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||||
|
file_put_contents($tmpFile, $cbzBinary);
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($tmpFile) !== true) {
|
||||||
|
unlink($tmpFile);
|
||||||
|
throw new \RuntimeException('Failed to open CBZ file as ZIP archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||||
|
$name = $zip->getNameIndex($i);
|
||||||
|
if (preg_match('/\.(jpg|jpeg|png|webp|gif)$/i', $name)) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
unlink($tmpFile);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,22 +13,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ImportChapterHandlerTest extends TestCase
|
class ImportChapterHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryMangaRepository $mangaRepository;
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
private InMemoryPathManager $pathManager;
|
private InMemoryImageStorage $imageStorage;
|
||||||
private ImportChapterHandler $handler;
|
private ImportChapterHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->mangaRepository = new InMemoryMangaRepository();
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
$this->pathManager = new InMemoryPathManager();
|
$this->imageStorage = new InMemoryImageStorage();
|
||||||
$this->handler = new ImportChapterHandler(
|
$this->handler = new ImportChapterHandler(
|
||||||
$this->mangaRepository,
|
$this->mangaRepository,
|
||||||
$this->pathManager
|
$this->imageStorage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryImageStorage;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class ImportVolumeHandlerTest extends TestCase
|
class ImportVolumeHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryMangaRepository $mangaRepository;
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
private InMemoryPathManager $pathManager;
|
private InMemoryImageStorage $imageStorage;
|
||||||
private ImportVolumeHandler $handler;
|
private ImportVolumeHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->mangaRepository = new InMemoryMangaRepository();
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
$this->pathManager = new InMemoryPathManager();
|
$this->imageStorage = new InMemoryImageStorage();
|
||||||
$this->handler = new ImportVolumeHandler(
|
$this->handler = new ImportVolumeHandler(
|
||||||
$this->mangaRepository,
|
$this->mangaRepository,
|
||||||
$this->pathManager
|
$this->imageStorage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,31 @@
|
|||||||
|
|
||||||
namespace App\Tests\Domain\Scraping\Adapter;
|
namespace App\Tests\Domain\Scraping\Adapter;
|
||||||
|
|
||||||
use App\Domain\Scraping\Domain\Contract\Service\ImageStorageInterface;
|
use App\Domain\Shared\Domain\Contract\ImageStorageInterface;
|
||||||
|
|
||||||
class InMemoryImageStorage implements ImageStorageInterface
|
class InMemoryImageStorage implements ImageStorageInterface
|
||||||
{
|
{
|
||||||
/** @var array<string, string> chapterId => pagesDirectory */
|
/** @var array<string, string> targetId => pagesDirectory */
|
||||||
public array $stored = [];
|
public array $stored = [];
|
||||||
|
|
||||||
public function storeChapterImages(string $chapterId, array $localImagePaths): string
|
public function storeChapterImages(string $targetId, array $localImagePaths): string
|
||||||
{
|
{
|
||||||
$dir = '/fake/pages/' . $chapterId;
|
$dir = '/fake/pages/' . $targetId;
|
||||||
$this->stored[$chapterId] = $dir;
|
$this->stored[$targetId] = $dir;
|
||||||
|
|
||||||
return $dir;
|
return $dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function extractFromCbz(string $targetId, string $cbzBinary): string
|
||||||
|
{
|
||||||
|
$dir = '/fake/pages/' . $targetId;
|
||||||
|
$this->stored[$targetId] = $dir;
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countCbzImages(string $cbzBinary): int
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user