Compare commits
10 Commits
f09f744a9b
...
21a87a3eb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a87a3eb3 | ||
|
|
ffceda606f | ||
|
|
b05bd98f63 | ||
|
|
9e7f7b4cfc | ||
|
|
50b33f53d7 | ||
|
|
3170a7c60e | ||
|
|
fbe9619224 | ||
|
|
8d14676656 | ||
|
|
bec1572fcb | ||
|
|
f1eb97f156 |
25
.vscode/settings.json
vendored
25
.vscode/settings.json
vendored
@@ -1,4 +1,25 @@
|
||||
{
|
||||
"symfony-vscode.shellExecutable": "/bin/bash",
|
||||
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- "
|
||||
}
|
||||
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- ",
|
||||
"workbench.colorCustomizations": {
|
||||
"activityBar.activeBackground": "#2f7c47",
|
||||
"activityBar.background": "#2f7c47",
|
||||
"activityBar.foreground": "#e7e7e7",
|
||||
"activityBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBarBadge.background": "#422c74",
|
||||
"activityBarBadge.foreground": "#e7e7e7",
|
||||
"commandCenter.border": "#e7e7e799",
|
||||
"sash.hoverBorder": "#2f7c47",
|
||||
"statusBar.background": "#215732",
|
||||
"statusBar.foreground": "#e7e7e7",
|
||||
"statusBarItem.hoverBackground": "#2f7c47",
|
||||
"statusBarItem.remoteBackground": "#215732",
|
||||
"statusBarItem.remoteForeground": "#e7e7e7",
|
||||
"titleBar.activeBackground": "#215732",
|
||||
"titleBar.activeForeground": "#e7e7e7",
|
||||
"titleBar.inactiveBackground": "#21573299",
|
||||
"titleBar.inactiveForeground": "#e7e7e799",
|
||||
"activityBar.activeBorder": "#422c74"
|
||||
},
|
||||
"peacock.color": "#215732"
|
||||
}
|
||||
|
||||
7
Makefile
7
Makefile
@@ -145,8 +145,11 @@ twig-extension: ## Create a new twig extension
|
||||
stimulus: ## Create a new stimulus controller
|
||||
@$(SYMFONY) make:stimulus-controller
|
||||
|
||||
consume:
|
||||
@$(SYMFONY) messenger:consume commands events -vv
|
||||
consume-commands: ## Consume commands messages
|
||||
@$(SYMFONY) messenger:consume commands -vv
|
||||
|
||||
consume-events: ## Consume events messages
|
||||
@$(SYMFONY) messenger:consume events -vv
|
||||
|
||||
consume-schedule: ## Consume schedule messages
|
||||
@$(SYMFONY) messenger:consume async -vv scheduler_default
|
||||
|
||||
@@ -13,7 +13,7 @@ Avant de commencer, assurez-vous que les outils suivants sont installés sur vot
|
||||
Pour mettre en place le projet, suivez ces étapes :
|
||||
|
||||
1. Clonez le dépôt du projet :
|
||||
```git clone git@bitbucket.org:tkm_rd/tkm-symfony.git```
|
||||
```git clone git@git.homelab.nestor-server.fr:2222/colgora/Mangarr.git```
|
||||
|
||||
2. Copiez le fichier `.env.example` en `.env` :
|
||||
```cp .env.example .env```
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<NotificationToast />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
<script setup>
|
||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -18,4 +17,4 @@ export default {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
217
assets/vue/app/domain/import/README.md
Normal file
217
assets/vue/app/domain/import/README.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Domaine Import - Analyse et Import de Fichiers CBZ/CBR
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce domaine permet l'import de fichiers CBZ/CBR dans Mangarr en utilisant l'analyse intelligente de noms de fichiers pour trouver automatiquement les correspondances avec les mangas de la bibliothèque.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Structure des Dossiers
|
||||
|
||||
```
|
||||
domain/import/
|
||||
├── domain/
|
||||
│ └── entities/
|
||||
│ └── FileImport.js # Entité représentant un fichier à importer
|
||||
├── infrastructure/
|
||||
│ └── api/
|
||||
│ └── apiImportRepository.js # Client API
|
||||
├── application/
|
||||
│ └── store/
|
||||
│ └── newImportStore.js # Store Pinia principal
|
||||
└── presentation/
|
||||
├── pages/
|
||||
│ └── NewImportPage.vue # Page principale d'import
|
||||
└── components/
|
||||
├── FileImportCard.vue # Carte de fichier à importer
|
||||
├── ImportResults.vue # Résumé des résultats
|
||||
└── StatusBadge.vue # Badge de statut
|
||||
```
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### 1. Upload de Fichiers
|
||||
|
||||
- **Drag & Drop** : Support du glisser-déposer pour les fichiers CBZ/CBR
|
||||
- **Sélection multiple** : Import de plusieurs fichiers simultanément
|
||||
- **Validation** : Vérification automatique des formats acceptés
|
||||
|
||||
### 2. Analyse Intelligente
|
||||
|
||||
- **Extraction automatique** : Le système analyse le nom de fichier pour extraire :
|
||||
- Le titre du manga
|
||||
- Le numéro de chapitre (si présent)
|
||||
- Le numéro de volume (si présent)
|
||||
|
||||
- **Correspondance automatique** :
|
||||
- Recherche des mangas correspondants dans la bibliothèque
|
||||
- Score de correspondance pour chaque résultat
|
||||
- Sélection automatique du meilleur match
|
||||
|
||||
### 3. Sélection et Validation
|
||||
|
||||
- **Sélection de manga** : Dropdown avec tous les mangas correspondants et leur score
|
||||
- **Prévisualisation** : Affichage de la couverture et des informations du manga sélectionné
|
||||
- **Édition des numéros** : Possibilité de modifier les numéros de chapitre/volume extraits
|
||||
- **Exclusivité** : Un fichier ne peut être importé que comme chapitre OU volume (pas les deux)
|
||||
|
||||
### 4. Import
|
||||
|
||||
- **Import unitaire** : Import fichier par fichier
|
||||
- **Import groupé** : Import de tous les fichiers prêts en une seule fois
|
||||
- **Retry** : Possibilité de réessayer en cas d'erreur
|
||||
- **Suivi en temps réel** : Indicateurs de progression et statuts
|
||||
|
||||
### 5. Résultats
|
||||
|
||||
- **Statistiques** : Nombre de fichiers importés, erreurs, total
|
||||
- **Détails** : Liste des fichiers importés avec leurs associations
|
||||
- **Erreurs** : Affichage détaillé des erreurs pour débogage
|
||||
|
||||
## API Endpoints Utilisés
|
||||
|
||||
### Analyse de fichiers
|
||||
```
|
||||
GET /api/manga-matches?filename={filename}
|
||||
```
|
||||
Retourne :
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"slug": "string",
|
||||
"alternativeSlugs": ["string"],
|
||||
"thumbnailUrl": "string",
|
||||
"matchScore": 100
|
||||
}
|
||||
],
|
||||
"chapterNumber": 1.5,
|
||||
"volumeNumber": 2.0,
|
||||
"possibleTitles": ["string"]
|
||||
}
|
||||
```
|
||||
|
||||
### Import de fichier
|
||||
```
|
||||
POST /api/chapters/import
|
||||
```
|
||||
FormData :
|
||||
- `file`: Le fichier CBZ à importer
|
||||
- `mangaId`: ID du manga
|
||||
- `chapterNumber`: Numéro de chapitre (float, optionnel)
|
||||
|
||||
Réponse (200) :
|
||||
```json
|
||||
{
|
||||
"message": "Chapter imported successfully",
|
||||
"mangaId": "uuid",
|
||||
"chapterNumber": 1.5
|
||||
}
|
||||
```
|
||||
|
||||
Erreurs :
|
||||
- `404`: Manga ou Chapitre non trouvé
|
||||
- `422`: Paramètres invalides ou fichier absent
|
||||
- `400`: Fichier CBZ invalide
|
||||
|
||||
### Import de volume (À venir)
|
||||
```
|
||||
POST /api/volumes/import
|
||||
```
|
||||
FormData :
|
||||
- `file`: Le fichier CBZ à importer
|
||||
- `mangaId`: ID du manga
|
||||
- `volumeNumber`: Numéro de volume (int)
|
||||
|
||||
## Store Pinia
|
||||
|
||||
Le store `newImportStore` gère tout l'état de l'application :
|
||||
|
||||
### État
|
||||
- `files`: Liste des fichiers en cours de traitement
|
||||
- `analyzingFiles`: Set des IDs de fichiers en analyse
|
||||
- `importingFiles`: Set des IDs de fichiers en import
|
||||
- `isLoading`: État de chargement global
|
||||
- `globalError`: Erreur globale éventuelle
|
||||
|
||||
### Getters
|
||||
- `pendingFiles`: Fichiers en attente d'analyse
|
||||
- `analyzedFiles`: Fichiers analysés
|
||||
- `readyFiles`: Fichiers prêts pour l'import
|
||||
- `importedFiles`: Fichiers importés avec succès
|
||||
- `errorFiles`: Fichiers en erreur
|
||||
- `hasReadyFiles`: Au moins un fichier prêt
|
||||
- `allFilesProcessed`: Tous les fichiers traités
|
||||
- `progressPercentage`: Pourcentage de progression
|
||||
|
||||
### Actions Principales
|
||||
- `addFiles(fileList)`: Ajoute des fichiers et lance l'analyse automatique
|
||||
- `analyzeFile(fileId)`: Analyse un fichier spécifique
|
||||
- `setFileManga(fileId, manga)`: Définit le manga sélectionné
|
||||
- `setFileChapterNumber(fileId, number)`: Définit le numéro de chapitre
|
||||
- `setFileVolumeNumber(fileId, number)`: Définit le numéro de volume
|
||||
- `importFile(fileId)`: Importe un fichier
|
||||
- `importAllReadyFiles()`: Importe tous les fichiers prêts
|
||||
- `autoSelectBestMatches()`: Sélection automatique des meilleurs matchs
|
||||
- `retryFile(fileId)`: Réessaye l'analyse ou l'import d'un fichier
|
||||
|
||||
## Entité FileImport
|
||||
|
||||
Représente un fichier dans le processus d'import :
|
||||
|
||||
### Propriétés
|
||||
- `file`: Objet File du navigateur
|
||||
- `filename`: Nom du fichier original
|
||||
- `analysis`: Résultat de l'analyse (matches, chapterNumber, volumeNumber)
|
||||
- `selectedManga`: Manga sélectionné par l'utilisateur
|
||||
- `selectedChapterNumber`: Numéro de chapitre (auto ou manuel)
|
||||
- `selectedVolumeNumber`: Numéro de volume (auto ou manuel)
|
||||
- `status`: pending | analyzed | importing | imported | error
|
||||
- `errorMessage`: Message d'erreur le cas échéant
|
||||
|
||||
### Méthodes Utiles
|
||||
- `hasMatches()`: Vérifie si des correspondances ont été trouvées
|
||||
- `getMatches()`: Retourne la liste des correspondances
|
||||
- `getBestMatch()`: Retourne la meilleure correspondance
|
||||
- `isReadyForImport()`: Vérifie si le fichier est prêt à être importé
|
||||
- `getImportData()`: Prépare les données pour l'API d'import
|
||||
|
||||
## Workflow Utilisateur
|
||||
|
||||
1. **Upload**: L'utilisateur glisse-dépose ou sélectionne des fichiers CBZ/CBR
|
||||
2. **Analyse automatique**: Chaque fichier est analysé pour extraire les informations
|
||||
3. **Sélection auto**: Le meilleur match est automatiquement sélectionné
|
||||
4. **Validation**: L'utilisateur peut modifier le manga ou les numéros si nécessaire
|
||||
5. **Import**: Import unitaire ou groupé des fichiers prêts
|
||||
6. **Résultats**: Affichage du résumé avec succès et erreurs
|
||||
|
||||
## Gestion des Erreurs
|
||||
|
||||
### Erreurs d'analyse
|
||||
- Aucun manga trouvé → Message informatif, possibilité de réessayer
|
||||
- Erreur réseau → Message d'erreur, bouton retry disponible
|
||||
|
||||
### Erreurs d'import
|
||||
- Échec d'upload → Fichier marqué en erreur avec message détaillé
|
||||
- Erreur serveur → Fichier en erreur, possibilité de retry
|
||||
|
||||
## Améliorations Futures
|
||||
|
||||
1. **Recherche manuelle** : Permettre la recherche manuelle si aucun match
|
||||
2. **Multi-sélection** : Sélectionner plusieurs fichiers pour actions groupées
|
||||
3. **Historique** : Garder un historique des imports récents
|
||||
4. **Validation avancée** : Vérifier si le chapitre/volume existe déjà
|
||||
5. **Métadonnées** : Extraire et afficher plus de métadonnées des fichiers CBZ
|
||||
|
||||
## Composants Réutilisables
|
||||
|
||||
### Depuis Shared
|
||||
- `FileUpload.vue`: Zone d'upload avec drag & drop
|
||||
- `LoadingSpinner.vue`: Indicateur de chargement
|
||||
|
||||
### Spécifiques au Domaine
|
||||
- `FileImportCard.vue`: Carte complète de gestion d'un fichier
|
||||
- `StatusBadge.vue`: Badge de statut avec couleurs
|
||||
- `ImportResults.vue`: Résumé des résultats d'import
|
||||
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||
import { FileImport } from '../../domain/entities/FileImport';
|
||||
import { ApiImportRepository } from '../../infrastructure/api/apiImportRepository';
|
||||
|
||||
const importRepository = new ApiImportRepository();
|
||||
const { showSuccess, showError, showInfo } = useNotifications();
|
||||
|
||||
export const useNewImportStore = defineStore('newImport', {
|
||||
state: () => ({
|
||||
// Files being processed
|
||||
files: [], // Array of FileImport entities
|
||||
|
||||
// Loading states
|
||||
analyzingFiles: new Set(), // File IDs being analyzed
|
||||
importingFiles: new Set(), // File IDs being imported
|
||||
|
||||
// Global states
|
||||
isLoading: false,
|
||||
globalError: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// File status getters
|
||||
pendingFiles: (state) => state.files.filter(f => f.isPending()),
|
||||
analyzedFiles: (state) => state.files.filter(f => f.isAnalyzed()),
|
||||
readyFiles: (state) => state.files.filter(f => f.isReadyForImport()),
|
||||
importedFiles: (state) => state.files.filter(f => f.isImported()),
|
||||
errorFiles: (state) => state.files.filter(f => f.hasError()),
|
||||
|
||||
// Counts
|
||||
totalFiles: (state) => state.files.length,
|
||||
readyCount: (state) => state.files.filter(f => f.isReadyForImport()).length,
|
||||
importedCount: (state) => state.files.filter(f => f.isImported()).length,
|
||||
errorCount: (state) => state.files.filter(f => f.hasError()).length,
|
||||
|
||||
// Status helpers
|
||||
hasFiles: (state) => state.files.length > 0,
|
||||
hasReadyFiles: (state) => state.files.some(f => f.isReadyForImport()),
|
||||
allFilesProcessed: (state) => {
|
||||
return state.files.length > 0 &&
|
||||
state.files.every(f => f.isImported() || f.hasError());
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressPercentage: (state) => {
|
||||
if (state.files.length === 0) return 0;
|
||||
const processed = state.files.filter(f => f.isImported() || f.hasError()).length;
|
||||
return Math.round((processed / state.files.length) * 100);
|
||||
},
|
||||
|
||||
// Specific file finders
|
||||
getFileById: (state) => (id) => {
|
||||
return state.files.find(f => f.id === id);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// === FILE MANAGEMENT ===
|
||||
|
||||
/**
|
||||
* Add files to the import queue
|
||||
*/
|
||||
addFiles(fileList) {
|
||||
const validFiles = Array.from(fileList).filter(file => {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
return ['cbz', 'cbr'].includes(extension);
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showError('Aucun fichier CBZ/CBR valide sélectionné');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = validFiles.map(file => FileImport.create(file));
|
||||
this.files.push(...newFiles);
|
||||
|
||||
showInfo(`${newFiles.length} fichier(s) ajouté(s) à la queue d'import`);
|
||||
|
||||
// Auto-analyze all new files
|
||||
this.analyzeAllPendingFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a file from the queue
|
||||
*/
|
||||
removeFile(fileId) {
|
||||
const index = this.files.findIndex(f => f.id === fileId);
|
||||
if (index !== -1) {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all files
|
||||
*/
|
||||
clearFiles() {
|
||||
this.files = [];
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
this.globalError = null;
|
||||
},
|
||||
|
||||
// === ANALYSIS ACTIONS ===
|
||||
|
||||
/**
|
||||
* Analyze all pending files
|
||||
*/
|
||||
async analyzeAllPendingFiles() {
|
||||
const pendingFiles = this.pendingFiles;
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingFiles.map(file => this.analyzeFile(file.id))
|
||||
);
|
||||
showSuccess(`${pendingFiles.length} fichier(s) analysé(s) avec succès`);
|
||||
} catch (error) {
|
||||
console.error('Error analyzing files:', error);
|
||||
this.globalError = 'Erreur lors de l\'analyse des fichiers';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Analyze a specific file
|
||||
*/
|
||||
async analyzeFile(fileId) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex === -1) return;
|
||||
|
||||
const file = this.files[fileIndex];
|
||||
if (!file.isPending()) return;
|
||||
|
||||
this.analyzingFiles.add(fileId);
|
||||
|
||||
try {
|
||||
const analysis = await importRepository.analyzeFilename(file.filename);
|
||||
file.setAnalysis(analysis);
|
||||
|
||||
// Force reactivity by replacing the object in the array
|
||||
this.files[fileIndex] = file;
|
||||
|
||||
if (!file.hasMatches()) {
|
||||
showError(`Aucun manga trouvé pour le fichier: ${file.filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error analyzing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'analyse: ${error.message}`);
|
||||
this.files[fileIndex] = file;
|
||||
showError(`Erreur lors de l'analyse de ${file.filename}`);
|
||||
} finally {
|
||||
this.analyzingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === SELECTION ACTIONS ===
|
||||
|
||||
/**
|
||||
* Update manga selection for a file
|
||||
*/
|
||||
setFileManga(fileId, manga) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedManga(manga);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update chapter number for a file
|
||||
*/
|
||||
setFileChapterNumber(fileId, chapterNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedChapterNumber(chapterNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update volume number for a file
|
||||
*/
|
||||
setFileVolumeNumber(fileId, volumeNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedVolumeNumber(volumeNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
// === IMPORT ACTIONS ===
|
||||
|
||||
/**
|
||||
* Import all ready files
|
||||
*/
|
||||
async importAllReadyFiles() {
|
||||
const readyFiles = this.readyFiles;
|
||||
if (readyFiles.length === 0) {
|
||||
showError('Aucun fichier prêt pour l\'import');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
for (const file of readyFiles) {
|
||||
try {
|
||||
await this.importFile(file.id);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`Failed to import file ${file.filename}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(`${successCount} fichier(s) importé(s) avec succès`);
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
showError(`${errorCount} fichier(s) ont échoué lors de l'import`);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Import a specific file
|
||||
*/
|
||||
async importFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file || !file.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
this.importingFiles.add(fileId);
|
||||
file.setImporting();
|
||||
|
||||
try {
|
||||
const importData = file.getImportData();
|
||||
await importRepository.importFile(
|
||||
file.file,
|
||||
importData.mangaId,
|
||||
importData.chapterNumber,
|
||||
importData.volumeNumber
|
||||
);
|
||||
|
||||
file.setImported();
|
||||
showSuccess(`Fichier ${file.filename} importé avec succès`);
|
||||
} catch (error) {
|
||||
console.error(`Error importing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'import: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.importingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry import for a failed file
|
||||
*/
|
||||
async retryFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file) return;
|
||||
|
||||
if (file.hasError() && file.selectedManga) {
|
||||
// If the file had an import error but has selections, retry import
|
||||
await this.importFile(fileId);
|
||||
} else {
|
||||
// If the file had an analysis error, retry analysis
|
||||
file.status = 'pending';
|
||||
file.errorMessage = null;
|
||||
await this.analyzeFile(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === UTILITY ACTIONS ===
|
||||
|
||||
/**
|
||||
* Auto-select best matches for all files
|
||||
*/
|
||||
autoSelectBestMatches() {
|
||||
let selectedCount = 0;
|
||||
|
||||
this.analyzedFiles.forEach(file => {
|
||||
const bestMatch = file.getBestMatch();
|
||||
if (bestMatch) {
|
||||
file.setSelectedManga(bestMatch);
|
||||
selectedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedCount > 0) {
|
||||
showInfo(`${selectedCount} correspondance(s) automatique(s) effectuée(s)`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset global state
|
||||
*/
|
||||
resetGlobalState() {
|
||||
this.globalError = null;
|
||||
this.isLoading = false;
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Entité représentant un fichier en cours d'import avec ses correspondances possibles
|
||||
*/
|
||||
export class FileImport {
|
||||
constructor({
|
||||
file, // File object from browser
|
||||
filename, // Original filename
|
||||
analysis = null, // Result from /api/manga-matches endpoint
|
||||
selectedManga = null, // Selected manga match
|
||||
selectedChapterNumber = null, // Selected chapter number (extracted from filename)
|
||||
selectedVolumeNumber = null, // Selected volume number (extracted from filename)
|
||||
status = 'pending', // 'pending', 'analyzed', 'importing', 'imported', 'error'
|
||||
errorMessage = null,
|
||||
importedAt = null
|
||||
}) {
|
||||
this.file = file;
|
||||
this.filename = filename;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterNumber = selectedChapterNumber;
|
||||
this.selectedVolumeNumber = selectedVolumeNumber;
|
||||
this.status = status;
|
||||
this.errorMessage = errorMessage;
|
||||
this.importedAt = importedAt;
|
||||
this.id = this._generateId();
|
||||
}
|
||||
|
||||
static create(file) {
|
||||
return new FileImport({
|
||||
file,
|
||||
filename: file.name
|
||||
});
|
||||
}
|
||||
|
||||
_generateId() {
|
||||
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Status helpers
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasMatches() {
|
||||
return this.analysis && this.analysis.matches && this.analysis.matches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.analysis?.matches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
// Sort by matchScore (highest first) and return the best one
|
||||
return matches.length > 0 ? matches.sort((a, b) => b.matchScore - a.matchScore)[0] : null;
|
||||
}
|
||||
|
||||
// Analysis extracted data
|
||||
getExtractedChapterNumber() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getExtractedVolumeNumber() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForImport() {
|
||||
// Ready if a manga is selected and at least chapter or volume number is set
|
||||
return this.selectedManga && (this.selectedChapterNumber !== null || this.selectedVolumeNumber !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterNumber !== null) return 'chapter';
|
||||
if (this.selectedVolumeNumber !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File helpers
|
||||
getFormattedSize() {
|
||||
if (!this.file || !this.file.size) return 'Unknown';
|
||||
|
||||
const bytes = this.file.size;
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getFileExtension() {
|
||||
const extension = this.filename.split('.').pop().toLowerCase();
|
||||
return extension;
|
||||
}
|
||||
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.getFileExtension());
|
||||
}
|
||||
|
||||
// Update methods
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
|
||||
// Auto-set extracted chapter/volume numbers from analysis
|
||||
if (analysis.chapterNumber !== null && analysis.chapterNumber !== undefined) {
|
||||
this.selectedChapterNumber = analysis.chapterNumber;
|
||||
}
|
||||
if (analysis.volumeNumber !== null && analysis.volumeNumber !== undefined) {
|
||||
this.selectedVolumeNumber = analysis.volumeNumber;
|
||||
}
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Keep the chapter/volume numbers from analysis
|
||||
}
|
||||
|
||||
setSelectedChapterNumber(chapterNumber) {
|
||||
this.selectedChapterNumber = chapterNumber;
|
||||
// If setting chapter, clear volume
|
||||
if (chapterNumber !== null) {
|
||||
this.selectedVolumeNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolumeNumber = volumeNumber;
|
||||
// If setting volume, clear chapter
|
||||
if (volumeNumber !== null) {
|
||||
this.selectedChapterNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.importedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterNumber !== null) {
|
||||
data.chapterNumber = this.selectedChapterNumber;
|
||||
}
|
||||
|
||||
if (this.selectedVolumeNumber !== null) {
|
||||
data.volumeNumber = this.selectedVolumeNumber;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
@@ -0,0 +1,239 @@
|
||||
export class ImportFile {
|
||||
constructor({
|
||||
id,
|
||||
originalName,
|
||||
fileSize,
|
||||
extension,
|
||||
status = 'pending',
|
||||
createdAt,
|
||||
metadata = null,
|
||||
mangaMatches = [],
|
||||
selectedMangaSlug = null,
|
||||
selectedVolume = null,
|
||||
selectedChapter = null,
|
||||
errorMessage = null,
|
||||
processedAt = null,
|
||||
// New properties for simplified workflow
|
||||
file = null, // Browser File object
|
||||
analysis = null, // Analysis result from API
|
||||
selectedManga = null, // Selected manga object
|
||||
selectedChapterId = null // Selected chapter ID
|
||||
}) {
|
||||
this.id = id;
|
||||
this.originalName = originalName;
|
||||
this.fileSize = fileSize;
|
||||
this.extension = extension;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.metadata = metadata;
|
||||
this.mangaMatches = mangaMatches;
|
||||
this.selectedMangaSlug = selectedMangaSlug;
|
||||
this.selectedVolume = selectedVolume;
|
||||
this.selectedChapter = selectedChapter;
|
||||
this.errorMessage = errorMessage;
|
||||
this.processedAt = processedAt;
|
||||
|
||||
// New properties
|
||||
this.file = file;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterId = selectedChapterId;
|
||||
this.mangaMatches = mangaMatches; // Store found manga matches
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
return new ImportFile({
|
||||
...data,
|
||||
createdAt: data.createdAt || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Create from browser File object
|
||||
static createFromFile(file) {
|
||||
return new ImportFile({
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
originalName: file.name,
|
||||
fileSize: file.size,
|
||||
extension: file.name.split('.').pop().toLowerCase(),
|
||||
file: file,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
isProcessed() {
|
||||
return this.status === 'processed';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
needsConversion() {
|
||||
return this.extension === 'cbr';
|
||||
}
|
||||
|
||||
isReadyForImport() {
|
||||
return this.isProcessed() && this.selectedMangaSlug && (this.selectedVolume || this.selectedChapter);
|
||||
}
|
||||
|
||||
getFormattedSize() {
|
||||
const bytes = parseInt(this.fileSize);
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getContentType() {
|
||||
if (this.metadata?.chapter) {
|
||||
return `Chapter ${this.metadata.chapter}`;
|
||||
}
|
||||
if (this.metadata?.volume) {
|
||||
return `Volume ${this.metadata.volume}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// === NEW METHODS FOR SIMPLIFIED WORKFLOW ===
|
||||
|
||||
// Status helpers for new workflow
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasAnalysis() {
|
||||
return this.analysis && this.analysis.possibleTitles && this.analysis.possibleTitles.length > 0;
|
||||
}
|
||||
|
||||
getPossibleTitles() {
|
||||
return this.analysis?.possibleTitles || [];
|
||||
}
|
||||
|
||||
getAnalyzedChapter() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getAnalyzedVolume() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
hasMatches() {
|
||||
return this.mangaMatches && this.mangaMatches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.mangaMatches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
return matches.length > 0 ? matches[0] : null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForNewImport() {
|
||||
return this.selectedManga && (this.selectedChapterId || this.selectedVolume !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterId) return 'chapter';
|
||||
if (this.selectedVolume !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File validation
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.extension);
|
||||
}
|
||||
|
||||
// Update methods for new workflow
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
}
|
||||
|
||||
setMangaMatches(matches) {
|
||||
this.mangaMatches = matches;
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Reset chapter/volume selection when manga changes
|
||||
this.selectedChapterId = null;
|
||||
this.selectedVolume = null;
|
||||
}
|
||||
|
||||
setSelectedChapterById(chapterId) {
|
||||
this.selectedChapterId = chapterId;
|
||||
this.selectedVolume = null; // Can't have both
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolume = volumeNumber;
|
||||
this.selectedChapterId = null; // Can't have both
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.processedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForNewImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterId) {
|
||||
data.chapterId = this.selectedChapterId;
|
||||
}
|
||||
|
||||
if (this.selectedVolume !== null) {
|
||||
data.volumeNumber = this.selectedVolume;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export class ApiImportRepository {
|
||||
/**
|
||||
* Analyse le nom d'un fichier et trouve les mangas correspondants
|
||||
* @param {string} filename - Nom du fichier à analyser
|
||||
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
|
||||
*/
|
||||
async analyzeFilename(filename) {
|
||||
try {
|
||||
console.log('Analyzing filename:', filename);
|
||||
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Analyze filename failed:', response.status, errorText);
|
||||
throw new Error(`Failed to analyze filename: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Analyze result:', result);
|
||||
|
||||
// Extract chapter and volume numbers from the first match if available
|
||||
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
|
||||
const chapterNumber = firstMatch?.chapterNumber ?? null;
|
||||
const volumeNumber = firstMatch?.volumeNumber ?? null;
|
||||
|
||||
return {
|
||||
matches: result.matches || [],
|
||||
chapterNumber,
|
||||
volumeNumber,
|
||||
possibleTitles: result.possibleTitles || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les détails d'un manga par son slug
|
||||
* @param {string} slug - Slug du manga
|
||||
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
|
||||
*/
|
||||
async getMangaDetails(slug) {
|
||||
try {
|
||||
console.log('Fetching manga details for:', slug);
|
||||
const response = await fetch(`/api/mangas/${slug}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Get manga details failed:', response.status, errorText);
|
||||
throw new Error(`Failed to get manga details: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload et import d'un fichier avec les informations du manga
|
||||
* @param {File} file - Fichier à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number|null} chapterNumber - Numéro du chapitre (optionnel)
|
||||
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importFile(file, mangaId, chapterNumber = null, volumeNumber = null) {
|
||||
try {
|
||||
// Déterminer s'il s'agit d'un import de chapitre ou volume
|
||||
if (chapterNumber !== null && chapterNumber !== undefined) {
|
||||
return await this.importChapter(file, mangaId, chapterNumber);
|
||||
} else if (volumeNumber !== null && volumeNumber !== undefined) {
|
||||
return await this.importVolume(file, mangaId, volumeNumber);
|
||||
} else {
|
||||
throw new Error('Either chapterNumber or volumeNumber must be provided');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import d'un chapitre
|
||||
* @param {File} file - Fichier CBZ à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number} chapterNumber - Numéro du chapitre
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importChapter(file, mangaId, chapterNumber) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mangaId', mangaId);
|
||||
formData.append('chapterNumber', chapterNumber.toString());
|
||||
|
||||
console.log('Importing chapter:', chapterNumber, 'for manga:', mangaId);
|
||||
const response = await fetch('/api/chapters/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Import failed:', response.status, errorText);
|
||||
|
||||
// Parse the error response if it's JSON
|
||||
let errorMessage = `Failed to import chapter: ${response.status}`;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorJson.details || errorMessage;
|
||||
} catch (e) {
|
||||
// Not JSON, use the status message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Import result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import d'un volume (TODO: À implémenter)
|
||||
* @param {File} file - Fichier CBZ à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {number} volumeNumber - Numéro du volume
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importVolume(file, mangaId, volumeNumber) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mangaId', mangaId);
|
||||
formData.append('volumeNumber', volumeNumber.toString());
|
||||
|
||||
console.log('Importing volume:', volumeNumber, 'for manga:', mangaId);
|
||||
const response = await fetch('/api/volumes/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Import failed:', response.status, errorText);
|
||||
|
||||
// Parse the error response if it's JSON
|
||||
let errorMessage = `Failed to import volume: ${response.status}`;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorJson.details || errorMessage;
|
||||
} catch (e) {
|
||||
// Not JSON, use the status message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Import result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- File Icon and Info -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- Extracted Info -->
|
||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
||||
Volume {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Selection -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||
Sélectionner un manga ({{ file.getMatches().length }} correspondance(s) trouvée(s))
|
||||
</label>
|
||||
|
||||
<!-- Matches Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<MangaMatchCard
|
||||
v-for="match in sortedMatches"
|
||||
:key="match.id"
|
||||
:match="match"
|
||||
:is-selected="file.selectedManga?.id === match.id"
|
||||
@select-match="handleMangaSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Manga Preview -->
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
:alt="file.selectedManga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
||||
<p class="text-xs text-blue-600 mt-1">Score: {{ file.selectedManga.matchScore }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter/Volume Number Inputs -->
|
||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||
<!-- Chapter Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Matches Message -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Retry Button -->
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import MangaMatchCard from './MangaMatchCard.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file'
|
||||
]);
|
||||
|
||||
// Computed property to get sorted matches
|
||||
const sortedMatches = computed(() => {
|
||||
const matches = props.file.getMatches();
|
||||
return matches.sort((a, b) => b.matchScore - a.matchScore);
|
||||
});
|
||||
|
||||
const handleMangaSelection = (selectedManga) => {
|
||||
emit('manga-selected', selectedManga);
|
||||
};
|
||||
|
||||
const handleChapterNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const chapterNumber = value ? parseFloat(value) : null;
|
||||
emit('chapter-number-selected', chapterNumber);
|
||||
};
|
||||
|
||||
const handleVolumeNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const volumeNumber = value ? parseFloat(value) : null;
|
||||
emit('volume-number-selected', volumeNumber);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-sm text-gray-500">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-sm text-gray-500">Erreurs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
||||
<div class="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="file in importedFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-gray-900">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="file in errorFiles"
|
||||
:key="file.id"
|
||||
class="flex items-start text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-gray-900">{{ file.filename }}</div>
|
||||
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useNewImportStore();
|
||||
|
||||
const importedFiles = computed(() => store.importedFiles);
|
||||
const errorFiles = computed(() => store.errorFiles);
|
||||
const importedCount = computed(() => store.importedCount);
|
||||
const errorCount = computed(() => store.errorCount);
|
||||
const totalCount = computed(() => store.totalFiles);
|
||||
|
||||
const startNewImport = () => {
|
||||
store.clearFiles();
|
||||
};
|
||||
|
||||
const goToLibrary = () => {
|
||||
router.push({ name: 'manga-collection' });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div
|
||||
class="border rounded-lg p-4 cursor-pointer transition-all duration-200 hover:shadow-md"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50': isSelected,
|
||||
'border-gray-200 hover:border-gray-300': !isSelected
|
||||
}"
|
||||
@click="$emit('select-match', match)"
|
||||
>
|
||||
<!-- Match Header with Score -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-300': !isSelected
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-medium text-gray-700">Score: {{ match.matchScore }}</span>
|
||||
</div>
|
||||
<div v-if="isSelected" class="text-blue-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Thumbnail -->
|
||||
<div class="flex space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
v-if="match.thumbnailUrl"
|
||||
:src="match.thumbnailUrl"
|
||||
:alt="match.title"
|
||||
class="w-16 h-20 object-cover rounded border"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-16 h-20 bg-gray-200 rounded border flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate" :title="match.title">
|
||||
{{ match.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate" :title="match.slug">
|
||||
{{ match.slug }}
|
||||
</p>
|
||||
|
||||
<!-- Alternative Slugs -->
|
||||
<div v-if="match.alternativeSlugs && match.alternativeSlugs.length > 0" class="mt-2">
|
||||
<p class="text-xs text-gray-400">Autres titres:</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="altSlug in match.alternativeSlugs.slice(0, 2)"
|
||||
:key="altSlug"
|
||||
class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded"
|
||||
>
|
||||
{{ altSlug }}
|
||||
</span>
|
||||
<span
|
||||
v-if="match.alternativeSlugs.length > 2"
|
||||
class="text-xs text-gray-400"
|
||||
>
|
||||
+{{ match.alternativeSlugs.length - 2 }} autres
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Bar -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Correspondance</span>
|
||||
<span>{{ match.matchScore }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-blue-500': isSelected,
|
||||
'bg-gray-400': !isSelected
|
||||
}"
|
||||
:style="{ width: match.matchScore + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
match: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-match']);
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="manga-option">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div v-if="manga.coverUrl" class="flex-shrink-0">
|
||||
<img
|
||||
:src="manga.coverUrl"
|
||||
:alt="manga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-shrink-0 w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ manga.title }}
|
||||
</h4>
|
||||
<div class="text-xs text-gray-500 space-y-1">
|
||||
<p v-if="manga.author" class="truncate">
|
||||
{{ manga.author }}
|
||||
</p>
|
||||
<p v-if="manga.publicationYear" class="truncate">
|
||||
{{ manga.publicationYear }}
|
||||
</p>
|
||||
<div v-if="manga.genres && manga.genres.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="genre in manga.genres.slice(0, 3)"
|
||||
:key="genre"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
<span v-if="manga.genres.length > 3" class="text-xs text-gray-400">
|
||||
+{{ manga.genres.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- Loading Spinner for analyzing/importing -->
|
||||
<svg v-if="isAnalyzing || isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span :class="badgeClasses">
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (props.isImporting) return 'Import en cours...';
|
||||
if (props.isAnalyzing) return 'Analyse en cours...';
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending': return 'En attente';
|
||||
case 'analyzed': return 'Analysé';
|
||||
case 'importing': return 'Import en cours';
|
||||
case 'imported': return 'Importé';
|
||||
case 'error': return 'Erreur';
|
||||
default: return 'Inconnu';
|
||||
}
|
||||
});
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600">
|
||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (if files are being processed) -->
|
||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Zone -->
|
||||
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="handleFilesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div v-if="store.hasFiles" class="space-y-6">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button
|
||||
v-if="store.hasReadyFiles"
|
||||
@click="importAllFiles"
|
||||
:disabled="store.isLoading"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
||||
Importer tous les fichiers prêts ({{ store.readyCount }})
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.analyzedFiles.length > 0"
|
||||
@click="autoSelectMatches"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Sélection automatique
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="clearAllFiles"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Effacer tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files Grid -->
|
||||
<div class="grid gap-6">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
||||
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary (when all files are processed) -->
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted } from 'vue';
|
||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
import FileImportCard from '../components/FileImportCard.vue';
|
||||
import ImportResults from '../components/ImportResults.vue';
|
||||
|
||||
const store = useNewImportStore();
|
||||
|
||||
// === EVENT HANDLERS ===
|
||||
|
||||
const handleFilesSelected = (files) => {
|
||||
store.addFiles(files);
|
||||
};
|
||||
|
||||
const importAllFiles = async () => {
|
||||
try {
|
||||
await store.importAllReadyFiles();
|
||||
} catch (error) {
|
||||
console.error('Error importing files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const importSingleFile = async (fileId) => {
|
||||
try {
|
||||
await store.importFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error importing file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const retryFile = async (fileId) => {
|
||||
try {
|
||||
await store.retryFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error retrying file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const autoSelectMatches = () => {
|
||||
store.autoSelectBestMatches();
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
||||
store.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
// Reset state when component unmounts
|
||||
onUnmounted(() => {
|
||||
store.resetGlobalState();
|
||||
});
|
||||
</script>
|
||||
@@ -80,7 +80,7 @@ export class ApiMangaRepository {
|
||||
|
||||
async searchMangas(query) {
|
||||
try {
|
||||
const response = await fetch(`/api/mangas/search?q=${encodeURIComponent(query)}`);
|
||||
const response = await fetch(`/api/manga-search?q=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search mangas');
|
||||
}
|
||||
|
||||
@@ -82,13 +82,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, onBeforeUnmount } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/vue';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/solid';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -147,7 +147,6 @@
|
||||
|
||||
try {
|
||||
await mangaStore.createFromMangaDex(selectedManga.value.externalId);
|
||||
await mangaStore.fetchMangaChapters(selectedManga.value.id);
|
||||
router.push('/manga');
|
||||
} catch (e) {
|
||||
console.error("Erreur d'ajout:", e);
|
||||
|
||||
@@ -17,21 +17,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
ArrowsUpDownIcon,
|
||||
FunnelIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
ArrowPathIcon,
|
||||
ArrowsUpDownIcon,
|
||||
Cog6ToothIcon,
|
||||
EyeIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
|
||||
import { useMangaStore } from '../../application/store/mangaStore';
|
||||
import MangaGrid from '../components/MangaGrid.vue';
|
||||
import MangaList from '../components/MangaList.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
@@ -60,7 +60,7 @@
|
||||
label: 'Refresh',
|
||||
type: 'button',
|
||||
onClick: () => mangaStore.refreshCollectionInBackground(),
|
||||
active: isBackgroundLoading
|
||||
active: isBackgroundLoading.value
|
||||
},
|
||||
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<div v-else-if="currentManga" class="relative">
|
||||
<!-- Composant invisible qui écoute les mises à jour Mercure -->
|
||||
<MercureListener :manga-id="mangaId" />
|
||||
<MercureListener :manga-id="String(mangaId)" />
|
||||
|
||||
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
|
||||
|
||||
@@ -129,7 +129,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
|
||||
const router = useRouter();
|
||||
const mangaStore = useMangaStore();
|
||||
|
||||
const mangaId = computed(() => route.params.id || null);
|
||||
const mangaId = computed(() => Number(route.params.id) || null);
|
||||
|
||||
// État de la modale
|
||||
const isPreferredSourcesModalOpen = ref(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
||||
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
||||
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
@@ -56,10 +57,16 @@ const routes = [
|
||||
component: ChapterPage,
|
||||
props: { title: 'Lecteur' }
|
||||
},
|
||||
// Import routes
|
||||
{
|
||||
path: '/import',
|
||||
name: 'import',
|
||||
component: NewImportPage
|
||||
},
|
||||
// Pages placeholder avec chargement différé
|
||||
{
|
||||
path: '/manga/import',
|
||||
name: 'import',
|
||||
name: 'manga-import',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Import de bibliothèque' }
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: ArrowDownTrayIcon,
|
||||
text: 'Import bibliothèque',
|
||||
to: '/manga/import'
|
||||
to: '/import'
|
||||
},
|
||||
{ icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' }
|
||||
]
|
||||
|
||||
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
||||
>
|
||||
<span>Sélectionner des fichiers</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</label>
|
||||
<p class="pl-1">ou glisser-déposer</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Choisir des fichiers'
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.cbz,.cbr'
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'CBZ ou CBR jusqu\'à 100MB chacun'
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'files-selected']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const isDragOver = ref(false);
|
||||
const selectedFiles = ref([]);
|
||||
|
||||
const inputId = computed(() => `file-upload-${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
selectedFiles.value = files;
|
||||
emit('update:modelValue', files);
|
||||
emit('files-selected', files);
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
isDragOver.value = false;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
selectedFiles.value = files;
|
||||
emit('update:modelValue', files);
|
||||
emit('files-selected', files);
|
||||
};
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newFiles) => {
|
||||
selectedFiles.value = newFiles;
|
||||
}, { deep: true });
|
||||
</script>
|
||||
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<svg
|
||||
class="animate-spin"
|
||||
:class="sizeClasses"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16'
|
||||
};
|
||||
return sizes[props.size];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -27,11 +27,15 @@ framework:
|
||||
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
|
||||
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
|
||||
'App\Domain\Manga\Application\Command\RefreshMangaChapters': commands
|
||||
# Events
|
||||
|
||||
# Events spécifiques (pour compatibilité, peuvent être supprimés si tous implémentent AsyncDomainEvent)
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events
|
||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
||||
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
||||
'App\Domain\Manga\Domain\Event\MangaCreated': events
|
||||
'App\Domain\Shared\Domain\Event\ChapterImported': events
|
||||
'App\Domain\Shared\Domain\Event\VolumeImported': events
|
||||
|
||||
# Legacy messages (à garder si nécessaire)
|
||||
'App\Message\DownloadChapter': commands
|
||||
|
||||
@@ -126,11 +126,13 @@ services:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
App\Domain\Scraping\Infrastructure\Service\CbzGenerator:
|
||||
App\Domain\Scraping\Infrastructure\Service\CbzGenerator: ~
|
||||
|
||||
# Shared Manga Path/File Manager
|
||||
App\Domain\Shared\Domain\Contract\MangaPathManagerInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Service\MangaFileManager
|
||||
|
||||
App\Domain\Shared\Infrastructure\Service\MangaFileManager:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
@@ -146,10 +148,6 @@ services:
|
||||
$publicDir: '%kernel.project_dir%/public'
|
||||
$httpClient: '@GuzzleHttp\Client'
|
||||
|
||||
App\Domain\Manga\Infrastructure\EventListener\MangaCreatedListener:
|
||||
tags:
|
||||
- { name: messenger.message_handler }
|
||||
|
||||
# Chapter Repository
|
||||
App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface:
|
||||
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyChapterRepository
|
||||
@@ -162,3 +160,35 @@ services:
|
||||
App\Domain\Manga\Infrastructure\Service\FileService:
|
||||
arguments:
|
||||
$cbzStoragePath: '%kernel.project_dir%/public/cbz'
|
||||
|
||||
App\Domain\Shared\Domain\Contract\EventDispatcherInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Messenger\SymfonyMessengerEventDispatcher
|
||||
|
||||
# Shared Domain Services Configuration
|
||||
App\Domain\Shared\Domain\Contract\FileUploadInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Service\SymfonyFileUpload
|
||||
|
||||
App\Domain\Shared\Infrastructure\Service\SymfonyFileUpload:
|
||||
arguments:
|
||||
$uploadsDirectory: '%kernel.project_dir%/public/tmp'
|
||||
|
||||
App\Domain\Shared\Domain\Contract\NotificationInterface:
|
||||
alias: App\Domain\Shared\Infrastructure\Service\SymfonyNotification
|
||||
|
||||
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
|
||||
tags:
|
||||
- { name: messenger.message_handler, bus: command.bus }
|
||||
|
||||
# Import Domain Services
|
||||
App\Domain\Import\Infrastructure\Service\FilenameAnalyzer: ~
|
||||
|
||||
App\Domain\Import\Domain\Service\FilenameAnalyzerInterface:
|
||||
alias: App\Domain\Import\Infrastructure\Service\FilenameAnalyzer
|
||||
|
||||
# Import Domain Query/Command Handlers
|
||||
App\Domain\Import\Application\QueryHandler\AnalyzeFilenameQueryHandler: ~
|
||||
App\Domain\Import\Application\CommandHandler\ImportFileCommandHandler: ~
|
||||
|
||||
# Import Domain API Platform Services
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\AnalyzeFilenameStateProcessor: ~
|
||||
App\Domain\Import\Infrastructure\ApiPlatform\State\Processor\ImportFileStateProcessor: ~
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"puppeteer": "^22.10.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"sortablejs": "^1.15.2",
|
||||
"tailwindcss": "^3.2.7"
|
||||
"tailwindcss": "^3.2.7",
|
||||
"vuedraggable": "^2.24.3"
|
||||
}
|
||||
}
|
||||
|
||||
12
src/Domain/Manga/Application/Command/ImportChapter.php
Normal file
12
src/Domain/Manga/Application/Command/ImportChapter.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\Command;
|
||||
|
||||
readonly class ImportChapter
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaId,
|
||||
public float $chapterNumber,
|
||||
public string $fileBinary
|
||||
) {}
|
||||
}
|
||||
16
src/Domain/Manga/Application/Command/ImportVolume.php
Normal file
16
src/Domain/Manga/Application/Command/ImportVolume.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\Command;
|
||||
|
||||
readonly class ImportVolume
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaId,
|
||||
public int $volumeNumber,
|
||||
public string $fileBinary
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -10,7 +11,7 @@ use App\Domain\Manga\Domain\Event\MangaCreated;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use App\Domain\Shared\Domain\Contract\EventDispatcherInterface;
|
||||
|
||||
readonly class CreateMangaFromMangadexHandler
|
||||
{
|
||||
@@ -18,7 +19,7 @@ readonly class CreateMangaFromMangadexHandler
|
||||
private MangaProviderInterface $mangaProvider,
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ImageProcessorInterface $imageProcessor,
|
||||
private MessageBusInterface $messageBus
|
||||
private EventDispatcherInterface $eventDispatcher
|
||||
) {}
|
||||
|
||||
public function handle(CreateMangaFromMangadex $command): void
|
||||
@@ -44,6 +45,6 @@ readonly class CreateMangaFromMangadexHandler
|
||||
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->messageBus->dispatch(new MangaCreated($manga->getId()->getValue(), $command->externalId));
|
||||
$this->eventDispatcher->dispatch(new MangaCreated($manga->getId()->getValue(), $command->externalId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangadexApiException;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
|
||||
readonly class FetchMangaChaptersHandler
|
||||
{
|
||||
@@ -18,7 +20,11 @@ readonly class FetchMangaChaptersHandler
|
||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||
|
||||
if ($manga === null) {
|
||||
throw new \RuntimeException('Manga not found');
|
||||
throw new MangaNotFoundException();
|
||||
}
|
||||
|
||||
if($manga->getExternalId() === null){
|
||||
throw new MangadexApiException("Manga has no external_id");
|
||||
}
|
||||
|
||||
// Synchronisation initiale (pas d'événements)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportChapter;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
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\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
readonly class ImportChapterHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
) {}
|
||||
|
||||
public function handle(ImportChapter $command): void
|
||||
{
|
||||
// 1. Validate that the manga exists
|
||||
$manga = $this->mangaRepository->findById($command->mangaId);
|
||||
if (!$manga) {
|
||||
throw new MangaNotFoundException($command->mangaId);
|
||||
}
|
||||
|
||||
// 2. Validate that the file is a valid CBZ
|
||||
if (!$this->isValidCbzFile($command->fileBinary)) {
|
||||
throw new \InvalidArgumentException('The provided file is not a valid CBZ file');
|
||||
}
|
||||
|
||||
// 3. Check if chapter exists
|
||||
$existingChapter = $this->chapterRepository->findByMangaIdAndChapterNumber(
|
||||
$command->mangaId,
|
||||
$command->chapterNumber
|
||||
);
|
||||
|
||||
if (!$existingChapter) {
|
||||
throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}");
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga, $existingChapter);
|
||||
|
||||
// 5. Update existing chapter with new CBZ path
|
||||
$updatedChapter = new Chapter(
|
||||
id: new ChapterId($existingChapter->getId()),
|
||||
mangaId: $existingChapter->getMangaId(),
|
||||
number: $existingChapter->getNumber(),
|
||||
title: $existingChapter->getTitle(),
|
||||
volume: $existingChapter->getVolume(),
|
||||
isVisible: $existingChapter->isVisible(),
|
||||
cbzPath: $cbzPath,
|
||||
createdAt: $existingChapter->getCreatedAt()
|
||||
);
|
||||
$this->chapterRepository->save($updatedChapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the binary data is a valid CBZ (ZIP) file
|
||||
*/
|
||||
private function isValidCbzFile(string $fileBinary): bool
|
||||
{
|
||||
// CBZ files are ZIP archives, check for ZIP magic number
|
||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the CBZ file to storage and return the path
|
||||
*/
|
||||
private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, Chapter $chapter): string
|
||||
{
|
||||
// Build the final CBZ path using the path manager (creates directories)
|
||||
$volumeNumber = $chapter->getVolume() ?? 0;
|
||||
$cbzPath = $this->pathManager->buildChapterCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$volumeNumber,
|
||||
(string)$command->chapterNumber
|
||||
);
|
||||
|
||||
// Write the binary content directly to the CBZ path
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
readonly class ImportVolumeHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
private MangaPathManagerInterface $pathManager
|
||||
) {}
|
||||
|
||||
public function handle(ImportVolume $command): void
|
||||
{
|
||||
// 1. Validate that the manga exists
|
||||
$manga = $this->mangaRepository->findById($command->mangaId);
|
||||
if (!$manga) {
|
||||
throw new MangaNotFoundException($command->mangaId);
|
||||
}
|
||||
|
||||
// 2. Validate that the file is a valid CBZ
|
||||
if (!$this->isValidCbzFile($command->fileBinary)) {
|
||||
throw new \InvalidArgumentException('The provided file is not a valid CBZ file');
|
||||
}
|
||||
|
||||
// 3. Get all chapters for this volume
|
||||
$chapters = $this->chapterRepository->findByMangaIdAndVolume(
|
||||
$command->mangaId,
|
||||
$command->volumeNumber
|
||||
);
|
||||
|
||||
if (empty($chapters)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Save the CBZ file to storage using the path manager
|
||||
$cbzPath = $this->saveCbzFile($command, $manga);
|
||||
|
||||
// 5. Update all chapters with the volume CBZ path
|
||||
foreach ($chapters as $chapter) {
|
||||
$updatedChapter = new Chapter(
|
||||
id: new ChapterId($chapter->getId()),
|
||||
mangaId: $chapter->getMangaId(),
|
||||
number: $chapter->getNumber(),
|
||||
title: $chapter->getTitle(),
|
||||
volume: $chapter->getVolume(),
|
||||
isVisible: $chapter->isVisible(),
|
||||
cbzPath: $cbzPath,
|
||||
createdAt: $chapter->getCreatedAt()
|
||||
);
|
||||
$this->chapterRepository->save($updatedChapter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the binary data is a valid CBZ (ZIP) file
|
||||
*/
|
||||
private function isValidCbzFile(string $fileBinary): bool
|
||||
{
|
||||
// CBZ files are ZIP archives, check for ZIP magic number
|
||||
$zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04
|
||||
|
||||
return strpos($fileBinary, $zipMagicNumber) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the CBZ file to storage and return the path
|
||||
*/
|
||||
private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string
|
||||
{
|
||||
// Build the final CBZ path using the path manager (creates directories)
|
||||
$cbzPath = $this->pathManager->buildVolumeCbzPath(
|
||||
$manga->getTitle()->getValue(),
|
||||
(string)$manga->getPublicationYear(),
|
||||
$command->volumeNumber
|
||||
);
|
||||
|
||||
// Write the binary content directly to the CBZ path
|
||||
if (!file_put_contents($cbzPath, $command->fileBinary)) {
|
||||
throw new \RuntimeException('Failed to save CBZ file');
|
||||
}
|
||||
|
||||
return $cbzPath;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\EventListener;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Shared\Domain\Event\ChapterImported;
|
||||
|
||||
readonly class ChapterImportedEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(ChapterImported $event): void
|
||||
{
|
||||
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
|
||||
if (!$manga) {
|
||||
return; // Manga introuvable, on ignore
|
||||
}
|
||||
|
||||
$chapters = $this->chapterRepository->findVisibleByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
||||
foreach ($chapters as $chapter) {
|
||||
if ($chapter->getNumber() === (float) $event->chapterNumber) {
|
||||
$updated = new Chapter(
|
||||
new ChapterId($chapter->getId()),
|
||||
$chapter->getMangaId(),
|
||||
$chapter->getNumber(),
|
||||
$chapter->getTitle(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->isVisible(),
|
||||
$event->cbzPath,
|
||||
$chapter->getCreatedAt(),
|
||||
);
|
||||
$this->chapterRepository->save($updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Application\EventListener;
|
||||
|
||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
|
||||
use App\Domain\Manga\Domain\Event\MangaCreated;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
|
||||
readonly class MangaCreatedEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private FetchMangaChaptersHandler $fetchMangaChaptersHandler
|
||||
) {}
|
||||
|
||||
public function __invoke(MangaCreated $event): void
|
||||
{
|
||||
$this->fetchMangaChaptersHandler->handle(
|
||||
new FetchMangaChapters(new MangaId($event->mangaId))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\EventListener;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Shared\Domain\Event\VolumeImported;
|
||||
|
||||
readonly class VolumeImportedEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private MangaRepositoryInterface $mangaRepository,
|
||||
private ChapterRepositoryInterface $chapterRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(VolumeImported $event): void
|
||||
{
|
||||
$manga = $this->mangaRepository->findBySlug(new MangaSlug($event->mangaSlug));
|
||||
if (!$manga) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chapters = $this->chapterRepository->findByMangaIdAndVolume($manga->getId()->getValue(), (int) $event->volume);
|
||||
if ($chapters === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$updated = new Chapter(
|
||||
new ChapterId($chapter->getId()),
|
||||
$chapter->getMangaId(),
|
||||
$chapter->getNumber(),
|
||||
$chapter->getTitle(),
|
||||
$chapter->getVolume(),
|
||||
$chapter->isVisible(),
|
||||
$event->cbzPath,
|
||||
$chapter->getCreatedAt(),
|
||||
);
|
||||
$this->chapterRepository->save($updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Query;
|
||||
|
||||
readonly class FindMangaMatchByFilename
|
||||
{
|
||||
public function __construct(
|
||||
public string $filename
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ readonly class DownloadVolumeHandler implements QueryHandlerInterface
|
||||
$cbzPaths[] = $chapter->getCbzPath();
|
||||
}
|
||||
|
||||
$volumeName = sprintf('%s-volume-%d',
|
||||
$volumeName = sprintf('%s_vol%d',
|
||||
$manga->getSlug()->getValue(),
|
||||
$query->volume
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
|
||||
use App\Domain\Manga\Application\Response\MangaMatchItem;
|
||||
use App\Domain\Manga\Application\Response\MangaMatchResponse;
|
||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
readonly class FindMangaMatchByFilenameHandler
|
||||
{
|
||||
public function __construct(
|
||||
private FilenameAnalyzerInterface $filenameAnalyzer,
|
||||
private MangaRepositoryInterface $mangaRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(FindMangaMatchByFilename $query): MangaMatchResponse
|
||||
{
|
||||
// Analyser le nom de fichier pour extraire les informations
|
||||
$analyzedFilename = $this->filenameAnalyzer->analyze($query->filename);
|
||||
|
||||
$searchedTitle = $analyzedFilename->getTitle()->getValue();
|
||||
$chapterNumber = $analyzedFilename->hasChapterNumber()
|
||||
? $analyzedFilename->getChapterNumber()->getValue()
|
||||
: null;
|
||||
$volumeNumber = $analyzedFilename->hasVolumeNumber()
|
||||
? $analyzedFilename->getVolumeNumber()->getValue()
|
||||
: null;
|
||||
|
||||
// Rechercher les mangas correspondants
|
||||
$foundMangas = $this->mangaRepository->search($searchedTitle, 1, 10);
|
||||
$matches = [];
|
||||
|
||||
foreach ($foundMangas as $manga) {
|
||||
$mangaId = $manga->getId()->getValue();
|
||||
|
||||
// Calculer un score de correspondance
|
||||
$matchScore = $this->calculateMatchScore(
|
||||
$manga,
|
||||
$searchedTitle
|
||||
);
|
||||
|
||||
$matches[] = new MangaMatchItem(
|
||||
id: $mangaId,
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
||||
matchScore: $matchScore,
|
||||
chapterNumber: $chapterNumber,
|
||||
volumeNumber: $volumeNumber
|
||||
);
|
||||
}
|
||||
|
||||
// Trier les résultats par score de correspondance (du plus élevé au plus faible)
|
||||
usort($matches, fn($a, $b) => $b->matchScore <=> $a->matchScore);
|
||||
|
||||
return new MangaMatchResponse(
|
||||
matches: $matches,
|
||||
chapterNumber: $chapterNumber,
|
||||
volumeNumber: $volumeNumber,
|
||||
possibleTitles: [$searchedTitle]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule un score de correspondance entre le manga et le titre recherché
|
||||
* Score plus élevé = meilleure correspondance
|
||||
*/
|
||||
private function calculateMatchScore(Manga $manga, string $searchedTitle): int
|
||||
{
|
||||
$score = 0;
|
||||
$mangaTitle = $manga->getTitle()->getValue();
|
||||
$mangaSlug = $manga->getSlug()->getValue();
|
||||
|
||||
// Correspondance exacte avec le titre
|
||||
if (strtolower($mangaTitle) === strtolower($searchedTitle)) {
|
||||
$score += 100;
|
||||
}
|
||||
|
||||
// Correspondance exacte avec le slug
|
||||
if (strtolower($mangaSlug) === strtolower($searchedTitle)) {
|
||||
$score += 90;
|
||||
}
|
||||
|
||||
// Correspondance avec les slugs alternatifs
|
||||
foreach ($manga->getAlternativeSlugs() as $altSlug) {
|
||||
if (strtolower($altSlug) === strtolower($searchedTitle)) {
|
||||
$score += 80;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Le titre du manga contient le terme recherché
|
||||
if (stripos($mangaTitle, $searchedTitle) !== false) {
|
||||
$score += 50;
|
||||
}
|
||||
|
||||
// Le terme recherché contient le titre du manga
|
||||
if (stripos($searchedTitle, $mangaTitle) !== false) {
|
||||
$score += 40;
|
||||
}
|
||||
|
||||
// Similarité de Levenshtein (pour les fautes de frappe)
|
||||
$levenshteinDistance = levenshtein(
|
||||
strtolower($mangaTitle),
|
||||
strtolower($searchedTitle)
|
||||
);
|
||||
|
||||
if ($levenshteinDistance <= 3) {
|
||||
$score += (3 - $levenshteinDistance) * 10;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ readonly class GetMangaBySlugHandler
|
||||
id: $manga->getId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
@@ -34,7 +35,8 @@ readonly class GetMangaBySlugHandler
|
||||
externalId: $manga->getExternalId()?->getValue(),
|
||||
imageUrl: $manga->getImageUrl(),
|
||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
rating: $manga->getRating(),
|
||||
monitored: $manga->isMonitored()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ readonly class SearchLocalMangaHandler
|
||||
id: $manga->getId()->getValue(),
|
||||
title: $manga->getTitle()->getValue(),
|
||||
slug: $manga->getSlug()->getValue(),
|
||||
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||
description: $manga->getDescription(),
|
||||
author: $manga->getAuthor(),
|
||||
publicationYear: $manga->getPublicationYear(),
|
||||
@@ -33,7 +34,8 @@ readonly class SearchLocalMangaHandler
|
||||
externalId: $manga->getExternalId()?->getValue() ?? '',
|
||||
imageUrl: $manga->getImageUrls()->getFull(),
|
||||
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
||||
rating: $manga->getRating()
|
||||
rating: $manga->getRating(),
|
||||
monitored: $manga->isMonitored()
|
||||
),
|
||||
$mangas
|
||||
),
|
||||
|
||||
21
src/Domain/Manga/Application/Response/MangaMatchItem.php
Normal file
21
src/Domain/Manga/Application/Response/MangaMatchItem.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Response;
|
||||
|
||||
readonly class MangaMatchItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $slug,
|
||||
public array $alternativeSlugs,
|
||||
public ?string $thumbnailUrl,
|
||||
public int $matchScore,
|
||||
public ?float $chapterNumber = null,
|
||||
public ?float $volumeNumber = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
30
src/Domain/Manga/Application/Response/MangaMatchResponse.php
Normal file
30
src/Domain/Manga/Application/Response/MangaMatchResponse.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Application\Response;
|
||||
|
||||
readonly class MangaMatchResponse
|
||||
{
|
||||
/**
|
||||
* @param MangaMatchItem[] $matches
|
||||
*/
|
||||
public function __construct(
|
||||
public array $matches,
|
||||
public ?float $chapterNumber,
|
||||
public ?float $volumeNumber,
|
||||
public array $possibleTitles
|
||||
) {
|
||||
}
|
||||
|
||||
public function hasMatches(): bool
|
||||
{
|
||||
return count($this->matches) > 0;
|
||||
}
|
||||
|
||||
public function getBestMatch(): ?MangaMatchItem
|
||||
{
|
||||
return $this->matches[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ChapterRepositoryInterface
|
||||
{
|
||||
public function findById(string $id): ?Chapter;
|
||||
public function findVisibleById(string $id): ?Chapter;
|
||||
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter;
|
||||
public function save(Chapter $chapter): void;
|
||||
public function delete(Chapter $chapter): void;
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Domain\Contract\Service;
|
||||
|
||||
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
|
||||
|
||||
interface FilenameAnalyzerInterface
|
||||
{
|
||||
public function analyze(string $filename): AnalyzedFilename;
|
||||
}
|
||||
45
src/Domain/Manga/Domain/Model/AnalyzedFilename.php
Normal file
45
src/Domain/Manga/Domain/Model/AnalyzedFilename.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Domain\Model;
|
||||
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
|
||||
|
||||
readonly class AnalyzedFilename
|
||||
{
|
||||
public function __construct(
|
||||
private MangaTitle $title,
|
||||
private ?ChapterNumber $chapterNumber = null,
|
||||
private ?VolumeNumber $volumeNumber = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function getTitle(): MangaTitle
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function getChapterNumber(): ?ChapterNumber
|
||||
{
|
||||
return $this->chapterNumber;
|
||||
}
|
||||
|
||||
public function getVolumeNumber(): ?VolumeNumber
|
||||
{
|
||||
return $this->volumeNumber;
|
||||
}
|
||||
|
||||
public function hasChapterNumber(): bool
|
||||
{
|
||||
return $this->chapterNumber !== null;
|
||||
}
|
||||
|
||||
public function hasVolumeNumber(): bool
|
||||
{
|
||||
return $this->volumeNumber !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,11 @@ final class Manga
|
||||
return $this->monitoringStatus->isEnabled();
|
||||
}
|
||||
|
||||
public function isMonitored(): bool
|
||||
{
|
||||
return $this->monitoringStatus->isEnabled();
|
||||
}
|
||||
|
||||
public function enableMonitoring(): void
|
||||
{
|
||||
$this->monitoringStatus = MonitoringStatus::enabled();
|
||||
|
||||
24
src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php
Normal file
24
src/Domain/Manga/Domain/Model/ValueObject/ChapterNumber.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Domain\Model\ValueObject;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class ChapterNumber
|
||||
{
|
||||
public function __construct(
|
||||
private float $value
|
||||
) {
|
||||
if ($value < 0) {
|
||||
throw new InvalidArgumentException('Chapter number cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
public function getValue(): float
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
|
||||
24
src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php
Normal file
24
src/Domain/Manga/Domain/Model/ValueObject/VolumeNumber.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Domain\Model\ValueObject;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class VolumeNumber
|
||||
{
|
||||
public function __construct(
|
||||
private float $value
|
||||
) {
|
||||
if ($value < 0) {
|
||||
throw new InvalidArgumentException('Volume number cannot be negative');
|
||||
}
|
||||
}
|
||||
|
||||
public function getValue(): float
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportChapter;
|
||||
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
|
||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportChapterResource;
|
||||
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 ImportChapterController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImportChapterHandler $commandHandler
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Get form parameters
|
||||
$mangaId = $request->request->get('mangaId');
|
||||
$chapterNumber = $request->request->get('chapterNumber');
|
||||
$uploadedFile = $request->files->get('file');
|
||||
|
||||
// Validate required fields
|
||||
if (!$mangaId) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'mangaId', 'message' => 'mangaId is required']
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!$chapterNumber) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'chapterNumber', 'message' => 'chapterNumber is required']
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!$uploadedFile) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'file', 'message' => 'Please upload a file']
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate file
|
||||
$errors = $this->validateFile($uploadedFile);
|
||||
if (!empty($errors)) {
|
||||
return $this->json($errors, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read file binary content
|
||||
$fileBinary = file_get_contents($uploadedFile->getPathname());
|
||||
if ($fileBinary === false) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file']
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Create the command
|
||||
$command = new ImportChapter(
|
||||
mangaId: $mangaId,
|
||||
chapterNumber: (float) $chapterNumber,
|
||||
fileBinary: $fileBinary
|
||||
);
|
||||
|
||||
// Execute the import
|
||||
$this->commandHandler->handle($command);
|
||||
|
||||
return $this->json([
|
||||
'message' => 'Chapter imported successfully',
|
||||
'mangaId' => $mangaId,
|
||||
'chapterNumber' => $chapterNumber
|
||||
], 200);
|
||||
|
||||
} catch (MangaNotFoundException $e) {
|
||||
return $this->json([
|
||||
'error' => 'Manga not found',
|
||||
'details' => $e->getMessage()
|
||||
], 404);
|
||||
|
||||
} catch (ChapterNotFoundException $e) {
|
||||
return $this->json([
|
||||
'error' => 'Chapter not found',
|
||||
'details' => $e->getMessage()
|
||||
], 404);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->json([
|
||||
'error' => 'Invalid file',
|
||||
'details' => $e->getMessage()
|
||||
], 400);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->json([
|
||||
'error' => 'Import failed',
|
||||
'details' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateFile($uploadedFile): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check if file is valid
|
||||
if (!$uploadedFile->isValid()) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
|
||||
];
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// Check file size (500MB max)
|
||||
$maxSize = 500 * 1024 * 1024; // 500MB in bytes
|
||||
if ($uploadedFile->getSize() > $maxSize) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is too large. Allowed size is 500MB.'
|
||||
];
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
$allowedExtensions = ['cbz'];
|
||||
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
||||
|
||||
if (!in_array($extension, $allowedExtensions)) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'Please upload a valid CBZ file'
|
||||
];
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportVolumeResource;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
|
||||
#[AsController]
|
||||
final class ImportVolumeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImportVolumeHandler $commandHandler
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
// Get form parameters
|
||||
$mangaId = $request->request->get('mangaId');
|
||||
$volumeNumber = $request->request->get('volumeNumber');
|
||||
$uploadedFile = $request->files->get('file');
|
||||
|
||||
// Validate required fields
|
||||
if (!$mangaId) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'mangaId', 'message' => 'mangaId is required']
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!$volumeNumber) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'volumeNumber', 'message' => 'volumeNumber is required']
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!$uploadedFile) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'file', 'message' => 'Please upload a file']
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate file
|
||||
$errors = $this->validateFile($uploadedFile);
|
||||
if (!empty($errors)) {
|
||||
return $this->json($errors, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Read file binary content
|
||||
$fileBinary = file_get_contents($uploadedFile->getPathname());
|
||||
if ($fileBinary === false) {
|
||||
return $this->json([
|
||||
['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file']
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Create the command
|
||||
$command = new ImportVolume(
|
||||
mangaId: $mangaId,
|
||||
volumeNumber: (int) $volumeNumber,
|
||||
fileBinary: $fileBinary
|
||||
);
|
||||
|
||||
// Execute the import
|
||||
$this->commandHandler->handle($command);
|
||||
|
||||
return $this->json([
|
||||
'message' => 'Volume imported successfully',
|
||||
'mangaId' => $mangaId,
|
||||
'volumeNumber' => (int) $volumeNumber
|
||||
], 200);
|
||||
|
||||
} catch (MangaNotFoundException $e) {
|
||||
return $this->json([
|
||||
'error' => 'Manga not found',
|
||||
'details' => $e->getMessage()
|
||||
], 404);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$statusCode = str_contains($e->getMessage(), 'not found') ? 404 : 400;
|
||||
return $this->json([
|
||||
'error' => 'Invalid request',
|
||||
'details' => $e->getMessage()
|
||||
], $statusCode);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->json([
|
||||
'error' => 'Import failed',
|
||||
'details' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateFile($uploadedFile): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
// Check if file is valid
|
||||
if (!$uploadedFile->isValid()) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage()
|
||||
];
|
||||
return $errors;
|
||||
}
|
||||
|
||||
// Check file size (500MB max)
|
||||
$maxSize = 500 * 1024 * 1024; // 500MB in bytes
|
||||
if ($uploadedFile->getSize() > $maxSize) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'The uploaded file is too large. Allowed size is 500MB.'
|
||||
];
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
$allowedExtensions = ['cbz'];
|
||||
$extension = strtolower($uploadedFile->getClientOriginalExtension());
|
||||
|
||||
if (!in_array($extension, $allowedExtensions)) {
|
||||
$errors[] = [
|
||||
'propertyPath' => 'file',
|
||||
'message' => 'Please upload a valid CBZ file'
|
||||
];
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
|
||||
|
||||
readonly class FilenameMatchCollection
|
||||
{
|
||||
/**
|
||||
* @param FilenameMatchItem[] $matches
|
||||
* @param string[] $possibleTitles
|
||||
*/
|
||||
public function __construct(
|
||||
public array $matches,
|
||||
public ?float $chapterNumber,
|
||||
public ?int $volumeNumber,
|
||||
public array $possibleTitles
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
|
||||
|
||||
readonly class FilenameMatchItem
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $slug,
|
||||
public array $alternativeSlugs,
|
||||
public ?string $thumbnailUrl,
|
||||
public int $matchScore,
|
||||
public ?float $chapterNumber,
|
||||
public ?float $volumeNumber
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\FindMangaMatchByFilenameStateProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'MangaMatch',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/manga-matches',
|
||||
provider: FindMangaMatchByFilenameStateProvider::class,
|
||||
openapiContext: [
|
||||
'summary' => 'Trouve des correspondances de manga à partir d\'un nom de fichier',
|
||||
'description' => 'Analyse un nom de fichier (cbz/cbr) et trouve les mangas correspondants dans la base de données. Extrait automatiquement le titre, le numéro de chapitre et le numéro de volume du nom de fichier.',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'filename',
|
||||
'in' => 'query',
|
||||
'required' => true,
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'example' => 'one-piece_vol108_ch1094.cbz'
|
||||
],
|
||||
'description' => 'Nom du fichier à analyser (avec ou sans extension .cbz/.cbr)'
|
||||
]
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Correspondances trouvées',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'matches' => [
|
||||
'type' => 'array',
|
||||
'description' => 'Liste des mangas correspondants triés par score de pertinence',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string', 'description' => 'Identifiant du manga'],
|
||||
'title' => ['type' => 'string', 'description' => 'Titre du manga'],
|
||||
'slug' => ['type' => 'string', 'description' => 'Slug du manga'],
|
||||
'alternativeSlugs' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
'description' => 'Slugs alternatifs'
|
||||
],
|
||||
'thumbnailUrl' => ['type' => 'string', 'nullable' => true, 'description' => 'URL de la miniature'],
|
||||
'matchScore' => ['type' => 'integer', 'description' => 'Score de correspondance (plus élevé = meilleure correspondance)'],
|
||||
'chapterNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de chapitre extrait'],
|
||||
'volumeNumber' => ['type' => 'number', 'nullable' => true, 'description' => 'Numéro de volume extrait']
|
||||
]
|
||||
]
|
||||
],
|
||||
'chapterNumber' => [
|
||||
'type' => 'number',
|
||||
'nullable' => true,
|
||||
'description' => 'Numéro de chapitre extrait du nom de fichier'
|
||||
],
|
||||
'volumeNumber' => [
|
||||
'type' => 'number',
|
||||
'nullable' => true,
|
||||
'description' => 'Numéro de volume extrait du nom de fichier'
|
||||
],
|
||||
'possibleTitles' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
'description' => 'Variantes de titres générées à partir du nom de fichier'
|
||||
]
|
||||
]
|
||||
],
|
||||
'example' => [
|
||||
'matches' => [
|
||||
[
|
||||
'id' => '123',
|
||||
'title' => 'One Piece',
|
||||
'slug' => 'one-piece',
|
||||
'alternativeSlugs' => [],
|
||||
'thumbnailUrl' => 'https://example.com/thumb.jpg',
|
||||
'matchScore' => 100,
|
||||
'chapterNumber' => 1094.0,
|
||||
'volumeNumber' => 108
|
||||
]
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'400' => [
|
||||
'description' => 'Nom de fichier manquant ou invalide'
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class FindMangaMatchByFilenameResource
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $matches = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportChapterController;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'ImportChapter',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/chapters/import',
|
||||
controller: ImportChapterController::class,
|
||||
deserialize: false,
|
||||
openapiContext: [
|
||||
'summary' => 'Import a chapter from CBZ file',
|
||||
'description' => 'Imports a CBZ file for an existing chapter and stores it',
|
||||
'requestBody' => [
|
||||
'content' => [
|
||||
'multipart/form-data' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'required' => ['mangaId', 'chapterNumber', 'file'],
|
||||
'properties' => [
|
||||
'mangaId' => [
|
||||
'type' => 'string',
|
||||
'format' => 'uuid',
|
||||
'description' => 'The manga UUID'
|
||||
],
|
||||
'chapterNumber' => [
|
||||
'type' => 'number',
|
||||
'description' => 'The chapter number (e.g., 1.5)'
|
||||
],
|
||||
'file' => [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
'description' => 'CBZ file to import (max 500MB)'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Chapter imported successfully',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'message' => ['type' => 'string'],
|
||||
'chapterId' => ['type' => 'string']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class ImportChapterResource
|
||||
{
|
||||
public ?string $mangaId = null;
|
||||
|
||||
public ?float $chapterNumber = null;
|
||||
|
||||
public ?File $file = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportVolumeController;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'ImportVolume',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/volumes/import',
|
||||
controller: ImportVolumeController::class,
|
||||
deserialize: false,
|
||||
openapiContext: [
|
||||
'summary' => 'Import a volume from CBZ file',
|
||||
'description' => 'Imports a CBZ file for an existing volume and updates all chapters',
|
||||
'requestBody' => [
|
||||
'content' => [
|
||||
'multipart/form-data' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'required' => ['mangaId', 'volumeNumber', 'file'],
|
||||
'properties' => [
|
||||
'mangaId' => [
|
||||
'type' => 'string',
|
||||
'format' => 'uuid',
|
||||
'description' => 'The manga UUID'
|
||||
],
|
||||
'volumeNumber' => [
|
||||
'type' => 'integer',
|
||||
'description' => 'The volume number'
|
||||
],
|
||||
'file' => [
|
||||
'type' => 'string',
|
||||
'format' => 'binary',
|
||||
'description' => 'CBZ file to import (max 500MB)'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Volume imported successfully',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'message' => ['type' => 'string'],
|
||||
'mangaId' => ['type' => 'string'],
|
||||
'volumeNumber' => ['type' => 'integer']
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class ImportVolumeResource
|
||||
{
|
||||
public ?string $mangaId = null;
|
||||
|
||||
public ?int $volumeNumber = null;
|
||||
|
||||
public ?File $file = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider;
|
||||
|
||||
#[ApiResource(
|
||||
shortName: 'Manga',
|
||||
shortName: 'MangaSearch',
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/mangas/search',
|
||||
new GetCollection(
|
||||
uriTemplate: '/manga-search',
|
||||
provider: SearchLocalMangaStateProvider::class,
|
||||
output: MangaSearchCollection::class,
|
||||
status: 200,
|
||||
@@ -82,4 +82,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaS
|
||||
)]
|
||||
class SearchLocalMangaResource
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
|
||||
use App\Domain\Manga\Application\QueryHandler\FindMangaMatchByFilenameHandler;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\FilenameMatchItem;
|
||||
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FindMangaMatchByFilenameResource;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
readonly class FindMangaMatchByFilenameStateProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private FindMangaMatchByFilenameHandler $handler
|
||||
) {
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): FindMangaMatchByFilenameResource
|
||||
{
|
||||
$filename = $context['filters']['filename'] ?? '';
|
||||
|
||||
if (empty($filename)) {
|
||||
throw new BadRequestHttpException('Le nom de fichier est requis');
|
||||
}
|
||||
|
||||
$query = new FindMangaMatchByFilename($filename);
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Pour Get, on retourne directement la resource
|
||||
return new FindMangaMatchByFilenameResource(
|
||||
matches: array_map(
|
||||
fn($match) => new FilenameMatchItem(
|
||||
id: $match->id,
|
||||
title: $match->title,
|
||||
slug: $match->slug,
|
||||
alternativeSlugs: $match->alternativeSlugs,
|
||||
thumbnailUrl: $match->thumbnailUrl,
|
||||
matchScore: $match->matchScore,
|
||||
chapterNumber: $match->chapterNumber,
|
||||
volumeNumber: $match->volumeNumber
|
||||
),
|
||||
$response->matches
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
|
||||
id: $response->id,
|
||||
title: $response->title,
|
||||
slug: $response->slug,
|
||||
alternativeSlugs: $response->alternativeSlugs,
|
||||
description: $response->description,
|
||||
author: $response->author,
|
||||
publicationYear: $response->publicationYear,
|
||||
@@ -31,7 +32,8 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
|
||||
externalId: $response->externalId,
|
||||
imageUrl: $response->imageUrl,
|
||||
thumbnailUrl: $response->thumbnailUrl,
|
||||
rating: $response->rating
|
||||
rating: $response->rating,
|
||||
monitored: $response->monitored
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\EventListener;
|
||||
|
||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||
use App\Domain\Manga\Domain\Event\MangaCreated;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
readonly class MangaCreatedListener
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $messageBus
|
||||
) {}
|
||||
|
||||
public function __invoke(MangaCreated $event): void
|
||||
{
|
||||
$this->messageBus->dispatch(
|
||||
new FetchMangaChapters($event->mangaId)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\MessageHandler;
|
||||
|
||||
use App\Domain\Manga\Application\EventListener\ChapterImportedEventListener;
|
||||
use App\Domain\Shared\Domain\Event\ChapterImported;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class ChapterImportedMessageHandler
|
||||
{
|
||||
public function __construct(private ChapterImportedEventListener $listener)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(ChapterImported $event): void
|
||||
{
|
||||
$this->listener->__invoke($event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\MessageHandler;
|
||||
|
||||
use App\Domain\Manga\Application\EventListener\MangaCreatedEventListener;
|
||||
use App\Domain\Manga\Domain\Event\MangaCreated;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class MangaCreatedMessageHandler
|
||||
{
|
||||
public function __construct(private MangaCreatedEventListener $listener)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(MangaCreated $event): void
|
||||
{
|
||||
$this->listener->__invoke($event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\MessageHandler;
|
||||
|
||||
use App\Domain\Manga\Application\EventListener\VolumeImportedEventListener;
|
||||
use App\Domain\Shared\Domain\Event\VolumeImported;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class VolumeImportedMessageHandler
|
||||
{
|
||||
public function __construct(private VolumeImportedEventListener $listener)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(VolumeImported $event): void
|
||||
{
|
||||
$this->listener->__invoke($event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -191,36 +191,59 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
|
||||
{
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
||||
->select('m')
|
||||
->from(EntityManga::class, 'm')
|
||||
->where('m.title LIKE :query')
|
||||
->orWhere('m.slug LIKE :query')
|
||||
// ->orWhere('m.author LIKE :query')
|
||||
// ->orWhere('m.description LIKE :query')
|
||||
->setParameter('query', '%' . $query . '%')
|
||||
->orderBy('m.title', 'ASC')
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($limit);
|
||||
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
||||
$sql = "SELECT m.* FROM manga m
|
||||
WHERE m.title LIKE :query
|
||||
OR m.slug LIKE :query
|
||||
OR CAST(m.alternative_slugs AS TEXT) LIKE :query
|
||||
ORDER BY m.title ASC
|
||||
LIMIT :limit OFFSET :offset";
|
||||
|
||||
$rsm = new \Doctrine\ORM\Query\ResultSetMapping();
|
||||
$rsm->addEntityResult(EntityManga::class, 'm');
|
||||
$rsm->addFieldResult('m', 'id', 'id');
|
||||
$rsm->addFieldResult('m', 'title', 'title');
|
||||
$rsm->addFieldResult('m', 'slug', 'slug');
|
||||
$rsm->addFieldResult('m', 'image_url', 'imageUrl');
|
||||
$rsm->addFieldResult('m', 'publication_year', 'publicationYear');
|
||||
$rsm->addFieldResult('m', 'description', 'description');
|
||||
$rsm->addFieldResult('m', 'genres', 'genres');
|
||||
$rsm->addFieldResult('m', 'created_at', 'createdAt');
|
||||
$rsm->addFieldResult('m', 'rating', 'rating');
|
||||
$rsm->addFieldResult('m', 'author', 'author');
|
||||
$rsm->addFieldResult('m', 'external_id', 'externalId');
|
||||
$rsm->addFieldResult('m', 'status', 'status');
|
||||
$rsm->addFieldResult('m', 'thumbnail_url', 'thumbnailUrl');
|
||||
$rsm->addFieldResult('m', 'monitored', 'monitored');
|
||||
$rsm->addFieldResult('m', 'last_monitoring_check', 'lastMonitoringCheck');
|
||||
$rsm->addFieldResult('m', 'alternative_slugs', 'AlternativeSlugs');
|
||||
|
||||
$nativeQuery = $this->entityManager->createNativeQuery($sql, $rsm);
|
||||
$nativeQuery->setParameter('query', '%' . $query . '%');
|
||||
$nativeQuery->setParameter('limit', $limit);
|
||||
$nativeQuery->setParameter('offset', $offset);
|
||||
|
||||
return array_map(
|
||||
fn (EntityManga $entity) => $this->toDomain($entity),
|
||||
$queryBuilder->getQuery()->getResult()
|
||||
$nativeQuery->getResult()
|
||||
);
|
||||
}
|
||||
|
||||
public function countSearch(string $query): int
|
||||
{
|
||||
return $this->entityManager->createQueryBuilder()
|
||||
->select('COUNT(m.id)')
|
||||
->from(EntityManga::class, 'm')
|
||||
->where('m.title LIKE :query')
|
||||
->orWhere('m.slug LIKE :query')
|
||||
->orWhere('m.author LIKE :query')
|
||||
->orWhere('m.description LIKE :query')
|
||||
->setParameter('query', '%' . $query . '%')
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
||||
$sql = "SELECT COUNT(m.id) FROM manga m
|
||||
WHERE m.title LIKE :query
|
||||
OR m.slug LIKE :query
|
||||
OR m.author LIKE :query
|
||||
OR m.description LIKE :query
|
||||
OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
|
||||
|
||||
$conn = $this->entityManager->getConnection();
|
||||
$stmt = $conn->prepare($sql);
|
||||
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
|
||||
|
||||
return (int) $result->fetchOne();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,21 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface
|
||||
return $entity ? $this->toDomainModel($entity) : null;
|
||||
}
|
||||
|
||||
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter
|
||||
{
|
||||
$qb = $this->entityManager->createQueryBuilder()
|
||||
->select('c')
|
||||
->from(ChapterEntity::class, 'c')
|
||||
->where('c.manga = :mangaId')
|
||||
->andWhere('c.number = :chapterNumber')
|
||||
->setParameter('mangaId', $mangaId)
|
||||
->setParameter('chapterNumber', $chapterNumber);
|
||||
|
||||
$entity = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
return $entity ? $this->toDomainModel($entity) : null;
|
||||
}
|
||||
|
||||
public function save(Chapter $chapter): void
|
||||
{
|
||||
$entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId());
|
||||
|
||||
@@ -114,7 +114,11 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
$mangas
|
||||
);
|
||||
|
||||
$ratings = $this->client->getMangaRatings($externalIds);
|
||||
try {
|
||||
$ratings = $this->client->getMangaRatings($externalIds);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($ratings['statistics'])) {
|
||||
foreach ($mangas as $manga) {
|
||||
@@ -146,4 +150,4 @@ readonly class MangadexProvider implements MangaProviderInterface
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal file
130
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Manga\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Service\FilenameAnalyzerInterface;
|
||||
use App\Domain\Manga\Domain\Model\AnalyzedFilename;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterNumber;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\VolumeNumber;
|
||||
|
||||
readonly class FilenameAnalyzer implements FilenameAnalyzerInterface
|
||||
{
|
||||
public function analyze(string $filename): AnalyzedFilename
|
||||
{
|
||||
// Enlever l'extension
|
||||
$nameWithoutExtension = preg_replace('/\.(cbz|cbr)$/i', '', $filename);
|
||||
|
||||
// Extraire les informations en utilisant la logique du CbzService
|
||||
$titleStr = $this->extractTitle($nameWithoutExtension);
|
||||
$volumeNumber = $this->extractVolume($nameWithoutExtension);
|
||||
$chapterNumber = $this->extractChapter($nameWithoutExtension);
|
||||
|
||||
$cleanedTitle = $this->cleanTitle($titleStr);
|
||||
|
||||
return new AnalyzedFilename(
|
||||
title: new MangaTitle($cleanedTitle),
|
||||
chapterNumber: $chapterNumber !== null ? new ChapterNumber($chapterNumber) : null,
|
||||
volumeNumber: $volumeNumber !== null ? new VolumeNumber((float) $volumeNumber) : null
|
||||
);
|
||||
}
|
||||
|
||||
private function extractTitle(string $fileName): string
|
||||
{
|
||||
// Pattern avec tirets complets : titre-volume-42-chapter-100 ou titre-chapitre-100-volume-42
|
||||
$fullDashCombinedPattern = '/^(?P<title>.*?)-(?:volume|chapter|chapitre)-\d+-(?:volume|chapter|chapitre)-\d+/i';
|
||||
if (preg_match($fullDashCombinedPattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Pattern avec tirets complets : titre-volume-42 ou titre-chapter-100 ou titre-chapitre-100
|
||||
$fullDashPattern = '/^(?P<title>.*?)-(?:volume|chapter|chapitre)-\d+/i';
|
||||
if (preg_match($fullDashPattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Pattern principal : titre suivi de volume/chapitre (inspiré du CbzService)
|
||||
// Supporte: vol, volume, tome, t, ch, chap, chapter, chapitre
|
||||
$titlePattern = '/^(?P<title>.+?)(?:\s*-\s*|\s+)?(?:(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Cc]h(?:ap(?:itre|ter)?)?|[Tt])\s*\d+)/';
|
||||
if (preg_match($titlePattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Pattern underscore : titre_vol123 ou titre_ch456 ou titre_chapter_1094
|
||||
$underscorePattern = '/^(?P<title>.*?)_(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
|
||||
if (preg_match($underscorePattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Pattern avec tiret : titre-vol123 ou titre-ch456 ou titre-tome-50
|
||||
$dashPattern = '/^(?P<title>.*?)-(?:vol|tome|t|ch|chap|chapter|chapitre)[\s_-]*\d+/i';
|
||||
if (preg_match($dashPattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Pattern underscore simple : titre_123
|
||||
$newFormatPattern = '/^(?P<title>.*?)_\d+/';
|
||||
if (preg_match($newFormatPattern, $fileName, $matches)) {
|
||||
return trim($matches['title']);
|
||||
}
|
||||
|
||||
// Si aucun pattern ne matche, retourner le nom sans extension
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
private function extractVolume(string $fileName): ?int
|
||||
{
|
||||
// Pattern pour volume : vol123, volume123, tome123, t123, v123
|
||||
$volumePattern = '/(?:[Tt]ome|[Vv]ol(?:ume)?\.?|[Tt]|[Vv])[\s\-_]*(?P<volume>\d+)/i';
|
||||
if (preg_match($volumePattern, $fileName, $matches)) {
|
||||
return (int) $matches['volume'];
|
||||
}
|
||||
|
||||
// Pattern avec tirets complets pour volume : volume-42
|
||||
$fullDashVolumePattern = '/volume-(?P<volume>\d+)/i';
|
||||
if (preg_match($fullDashVolumePattern, $fileName, $matches)) {
|
||||
return (int) $matches['volume'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractChapter(string $fileName): ?float
|
||||
{
|
||||
// Pattern pour chapitre : ch123, chap123, chapter123, chapitre123
|
||||
$chapterPattern = '/[Cc]h(?:ap(?:itre|ter)?)?[\s\-_]*(?P<chapter>\d+(?:\.\d+)?)/i';
|
||||
if (preg_match($chapterPattern, $fileName, $matches)) {
|
||||
return (float) $matches['chapter'];
|
||||
}
|
||||
|
||||
// Pattern avec tirets complets pour chapitre : chapter-100 ou chapitre-100
|
||||
$fullDashChapterPattern = '/(?:chapter|chapitre)-(?P<chapter>\d+(?:\.\d+)?)/i';
|
||||
if (preg_match($fullDashChapterPattern, $fileName, $matches)) {
|
||||
return (float) $matches['chapter'];
|
||||
}
|
||||
|
||||
// Pattern underscore à la fin : _123.cbz
|
||||
$newFormatPattern = '/_ch(?P<chapter>\d+(?:\.\d+)?)(?:\.\w+)?$/i';
|
||||
if (preg_match($newFormatPattern, $fileName, $matches)) {
|
||||
return (float) $matches['chapter'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function cleanTitle(string $title): string
|
||||
{
|
||||
// Enlever les patterns communs (avec séparateurs possibles)
|
||||
$cleanTitle = preg_replace('/[\s\-_]?(?:scan|raw|fr|en|jp|hq|lq)[\s\-_]?/i', ' ', $title);
|
||||
|
||||
// Enlever les caractères spéciaux en début/fin
|
||||
$cleanTitle = trim($cleanTitle, ' -_.');
|
||||
|
||||
// Normaliser les espaces multiples
|
||||
$cleanTitle = preg_replace('/\s+/', ' ', $cleanTitle);
|
||||
|
||||
return trim($cleanTitle);
|
||||
}
|
||||
}
|
||||
@@ -5,84 +5,24 @@ namespace App\Domain\Scraping\Infrastructure\Service;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
readonly class CbzGenerator implements CbzGeneratorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $projectDir
|
||||
private MangaPathManagerInterface $mangaPathManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function generate(CbzGenerationRequest $request): CbzPath
|
||||
{
|
||||
$cbzPath = $this->generateCbzPath($request);
|
||||
$this->createCbzArchive($request->getFiles(), $cbzPath);
|
||||
$cbzPath = $this->mangaPathManager->buildChapterCbzPath(
|
||||
$request->getMangaTitle(),
|
||||
$request->getPublicationYear(),
|
||||
$request->getVolumeNumber(),
|
||||
$request->getChapterNumber(),
|
||||
);
|
||||
$this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath);
|
||||
return new CbzPath($cbzPath);
|
||||
}
|
||||
|
||||
private function generateCbzPath(CbzGenerationRequest $request): string
|
||||
{
|
||||
$mangaDir = $this->createMangaDirectory(
|
||||
$this->slugify($request->getMangaTitle()),
|
||||
$request->getPublicationYear()
|
||||
);
|
||||
|
||||
$volumeDir = $this->createVolumeDirectory($mangaDir, $request->getVolumeNumber());
|
||||
|
||||
return sprintf(
|
||||
'%s/%s_vol%d_ch%s.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($request->getMangaTitle()),
|
||||
$request->getVolumeNumber(),
|
||||
$request->getChapterNumber()
|
||||
);
|
||||
}
|
||||
|
||||
private function createCbzArchive(array $files, string $cbzPath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Failed to create CBZ archive');
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!file_exists($file)) {
|
||||
throw new \RuntimeException("File not found: $file");
|
||||
}
|
||||
$zip->addFile($file, basename($file));
|
||||
}
|
||||
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException('Failed to close CBZ archive');
|
||||
}
|
||||
}
|
||||
|
||||
private function createMangaDirectory(string $mangaSlug, string $publicationYear): string
|
||||
{
|
||||
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, ucfirst($mangaSlug) . ' (' . $publicationYear . ')');
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException("Failed to create directory: $dir");
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function createVolumeDirectory(string $mangaDir, int $volumeNumber): string
|
||||
{
|
||||
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||
throw new \RuntimeException("Failed to create directory: $dir");
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private function slugify(string $text): string
|
||||
{
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
$text = strtolower($text);
|
||||
return $text ?: 'n-a';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface EventDispatcherInterface
|
||||
{
|
||||
public function dispatch(object $event): void;
|
||||
}
|
||||
40
src/Domain/Shared/Domain/Contract/FileUploadInterface.php
Normal file
40
src/Domain/Shared/Domain/Contract/FileUploadInterface.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
use App\Domain\Shared\Domain\Model\FileUpload;
|
||||
|
||||
interface FileUploadInterface
|
||||
{
|
||||
/**
|
||||
* Déplace un fichier uploadé vers un répertoire temporaire
|
||||
*/
|
||||
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string;
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier existe
|
||||
*/
|
||||
public function fileExists(string $filePath): bool;
|
||||
|
||||
/**
|
||||
* Supprime un fichier
|
||||
*/
|
||||
public function deleteFile(string $filePath): void;
|
||||
|
||||
/**
|
||||
* Déplace un fichier d'un emplacement à un autre
|
||||
*/
|
||||
public function moveFile(string $sourcePath, string $targetPath): void;
|
||||
|
||||
/**
|
||||
* Crée un répertoire s'il n'existe pas
|
||||
*/
|
||||
public function createDirectory(string $path): void;
|
||||
|
||||
/**
|
||||
* Valide le format d'un fichier
|
||||
*/
|
||||
public function validateFileFormat(string $filePath, array $allowedExtensions): bool;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Service centralisé de gestion des chemins et de l'enregistrement des fichiers
|
||||
* liés aux mangas (manga/volume/chapter) et des archives CBZ.
|
||||
*/
|
||||
interface MangaPathManagerInterface
|
||||
{
|
||||
/**
|
||||
* Retourne (et crée si nécessaire) le dossier du manga.
|
||||
*/
|
||||
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string;
|
||||
|
||||
/**
|
||||
* Retourne (et crée si nécessaire) le dossier du volume.
|
||||
*/
|
||||
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
|
||||
|
||||
/**
|
||||
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de chapitre.
|
||||
*/
|
||||
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string;
|
||||
|
||||
/**
|
||||
* Construit (et garantit l'existence des dossiers) le chemin complet d'un CBZ de volume.
|
||||
*/
|
||||
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string;
|
||||
|
||||
/**
|
||||
* Crée une archive CBZ à partir d'une liste de fichiers et l'écrit au chemin fourni.
|
||||
*
|
||||
* @param array<int, string> $files Chemins absolus des fichiers à packager
|
||||
*/
|
||||
public function createCbzArchive(array $files, string $cbzPath): void;
|
||||
|
||||
/**
|
||||
* Déplace un fichier existant vers une destination. Crée les dossiers si nécessaire.
|
||||
*/
|
||||
public function moveFileTo(string $sourcePath, string $destinationPath): void;
|
||||
|
||||
/**
|
||||
* Indique si un fichier existe et est lisible.
|
||||
*/
|
||||
public function fileExists(string $path): bool;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
use App\Domain\Shared\Domain\Model\FileMetadata;
|
||||
|
||||
interface MetadataExtractorInterface
|
||||
{
|
||||
/**
|
||||
* Extrait les métadonnées d'un fichier
|
||||
*/
|
||||
public function extractMetadata(string $filePath, string $originalFileName): FileMetadata;
|
||||
|
||||
/**
|
||||
* Vérifie si le fichier peut être traité par cet extracteur
|
||||
*/
|
||||
public function canHandle(string $filePath): bool;
|
||||
}
|
||||
23
src/Domain/Shared/Domain/Contract/NotificationInterface.php
Normal file
23
src/Domain/Shared/Domain/Contract/NotificationInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Contract;
|
||||
|
||||
interface NotificationInterface
|
||||
{
|
||||
/**
|
||||
* Envoie une notification de succès
|
||||
*/
|
||||
public function sendSuccess(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification d'erreur
|
||||
*/
|
||||
public function sendError(string $message): void;
|
||||
|
||||
/**
|
||||
* Envoie une notification avec un statut personnalisé
|
||||
*/
|
||||
public function sendUpdate(array $data): void;
|
||||
}
|
||||
17
src/Domain/Shared/Domain/Event/ChapterImported.php
Normal file
17
src/Domain/Shared/Domain/Event/ChapterImported.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Event;
|
||||
|
||||
readonly class ChapterImported
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaSlug,
|
||||
public int $volume,
|
||||
public float|string $chapterNumber,
|
||||
public string $cbzPath,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
16
src/Domain/Shared/Domain/Event/VolumeImported.php
Normal file
16
src/Domain/Shared/Domain/Event/VolumeImported.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Event;
|
||||
|
||||
readonly class VolumeImported
|
||||
{
|
||||
public function __construct(
|
||||
public string $mangaSlug,
|
||||
public int $volume,
|
||||
public string $cbzPath,
|
||||
) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Exception;
|
||||
|
||||
class FileProcessingException extends \RuntimeException
|
||||
{
|
||||
public static function invalidFormat(string $fileName, array $allowedFormats): self
|
||||
{
|
||||
return new self(
|
||||
sprintf(
|
||||
'Le fichier "%s" doit être au format %s.',
|
||||
$fileName,
|
||||
implode(' ou ', $allowedFormats)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function uploadFailed(string $fileName, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('Une erreur est survenue lors de l\'upload du fichier "%s" : %s', $fileName, $reason)
|
||||
);
|
||||
}
|
||||
|
||||
public static function fileNotFound(string $filePath): self
|
||||
{
|
||||
return new self(sprintf('Le fichier "%s" n\'a pas été trouvé.', $filePath));
|
||||
}
|
||||
|
||||
public static function metadataExtractionFailed(string $fileName, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('Impossible d\'extraire les métadonnées du fichier "%s" : %s', $fileName, $reason)
|
||||
);
|
||||
}
|
||||
}
|
||||
42
src/Domain/Shared/Domain/Model/FileMetadata.php
Normal file
42
src/Domain/Shared/Domain/Model/FileMetadata.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Model;
|
||||
|
||||
readonly class FileMetadata
|
||||
{
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public ?int $volume = null,
|
||||
public ?int $chapter = null,
|
||||
public ?string $author = null,
|
||||
public ?string $description = null,
|
||||
public array $additionalData = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
title: $data['title'] ?? '',
|
||||
volume: $data['volume'] ?? null,
|
||||
chapter: $data['chapter'] ?? null,
|
||||
author: $data['author'] ?? null,
|
||||
description: $data['description'] ?? null,
|
||||
additionalData: $data['additionalData'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'volume' => $this->volume,
|
||||
'chapter' => $this->chapter,
|
||||
'author' => $this->author,
|
||||
'description' => $this->description,
|
||||
'additionalData' => $this->additionalData,
|
||||
];
|
||||
}
|
||||
}
|
||||
49
src/Domain/Shared/Domain/Model/FileUpload.php
Normal file
49
src/Domain/Shared/Domain/Model/FileUpload.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Domain\Model;
|
||||
|
||||
readonly class FileUpload
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $originalName,
|
||||
public string $path,
|
||||
public string $extension,
|
||||
public int $size,
|
||||
public \DateTimeImmutable $uploadedAt
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $originalName,
|
||||
string $path,
|
||||
int $size
|
||||
): self {
|
||||
return new self(
|
||||
id: uniqid('file_', true),
|
||||
originalName: $originalName,
|
||||
path: $path,
|
||||
extension: strtolower(pathinfo($originalName, PATHINFO_EXTENSION)),
|
||||
size: $size,
|
||||
uploadedAt: new \DateTimeImmutable()
|
||||
);
|
||||
}
|
||||
|
||||
public function isValidFormat(array $allowedExtensions): bool
|
||||
{
|
||||
return in_array($this->extension, $allowedExtensions, true);
|
||||
}
|
||||
|
||||
public function getFormattedSize(int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($this->size, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Messenger;
|
||||
use App\Domain\Shared\Domain\Contract\EventDispatcherInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
class SymfonyMessengerEventDispatcher implements EventDispatcherInterface
|
||||
{
|
||||
public function __construct(private MessageBusInterface $bus)
|
||||
{
|
||||
}
|
||||
|
||||
public function dispatch(object $event): void
|
||||
{
|
||||
$this->bus->dispatch($event);
|
||||
}
|
||||
}
|
||||
111
src/Domain/Shared/Infrastructure/Service/MangaFileManager.php
Normal file
111
src/Domain/Shared/Infrastructure/Service/MangaFileManager.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
/**
|
||||
* Implémentation centralisée basée sur la logique éprouvée de CbzGenerator.
|
||||
*/
|
||||
readonly class MangaFileManager implements MangaPathManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private FileUploadInterface $fileUpload,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string
|
||||
{
|
||||
$mangaDirName = ucfirst($this->slugify($mangaTitle)) . ' (' . $publicationYear . ')';
|
||||
$dir = sprintf('%s/public/cbz/%s', $this->projectDir, $mangaDirName);
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
$mangaDir = $this->getMangaDirectory($mangaTitle, $publicationYear);
|
||||
$dir = sprintf('%s/volume_%02d', $mangaDir, $volumeNumber);
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string
|
||||
{
|
||||
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
|
||||
return sprintf(
|
||||
'%s/%s_vol%d_ch%s.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($mangaTitle),
|
||||
$volumeNumber,
|
||||
$chapterNumber,
|
||||
);
|
||||
}
|
||||
|
||||
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
$volumeDir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
|
||||
return sprintf(
|
||||
'%s/%s_vol%d.cbz',
|
||||
$volumeDir,
|
||||
$this->slugify($mangaTitle),
|
||||
$volumeNumber,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<int, string> $files */
|
||||
public function createCbzArchive(array $files, string $cbzPath): void
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($cbzPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Failed to create CBZ archive');
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!file_exists($file)) {
|
||||
throw new \RuntimeException("File not found: $file");
|
||||
}
|
||||
$zip->addFile($file, basename($file));
|
||||
}
|
||||
|
||||
if (!$zip->close()) {
|
||||
throw new \RuntimeException('Failed to close CBZ archive');
|
||||
}
|
||||
}
|
||||
|
||||
public function moveFileTo(string $sourcePath, string $destinationPath): void
|
||||
{
|
||||
$destinationDir = dirname($destinationPath);
|
||||
$this->ensureDirectory($destinationDir);
|
||||
$this->fileUpload->moveFile($sourcePath, $destinationPath);
|
||||
}
|
||||
|
||||
public function fileExists(string $path): bool
|
||||
{
|
||||
return $this->fileUpload->fileExists($path);
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
$this->fileUpload->createDirectory($dir);
|
||||
}
|
||||
}
|
||||
|
||||
private function slugify(string $text): string
|
||||
{
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
$text = strtolower($text);
|
||||
return $text ?: 'n-a';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\FileUploadInterface;
|
||||
use App\Domain\Shared\Domain\Exception\FileProcessingException;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||
|
||||
readonly class SymfonyFileUpload implements FileUploadInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Filesystem $filesystem,
|
||||
private string $uploadsDirectory
|
||||
) {
|
||||
}
|
||||
|
||||
public function moveUploadedFile(string $sourcePath, string $targetDirectory, string $originalName): string
|
||||
{
|
||||
try {
|
||||
$targetPath = $targetDirectory . '/' . uniqid() . '_' . $originalName;
|
||||
$this->filesystem->copy($sourcePath, $targetPath);
|
||||
|
||||
return $targetPath;
|
||||
} catch (FileException $e) {
|
||||
throw FileProcessingException::uploadFailed($originalName, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function fileExists(string $filePath): bool
|
||||
{
|
||||
return $this->filesystem->exists($filePath);
|
||||
}
|
||||
|
||||
public function deleteFile(string $filePath): void
|
||||
{
|
||||
if ($this->filesystem->exists($filePath)) {
|
||||
$this->filesystem->remove($filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public function moveFile(string $sourcePath, string $targetPath): void
|
||||
{
|
||||
try {
|
||||
$this->filesystem->rename($sourcePath, $targetPath, true);
|
||||
} catch (FileException $e) {
|
||||
throw FileProcessingException::uploadFailed(
|
||||
basename($sourcePath),
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function createDirectory(string $path): void
|
||||
{
|
||||
if (!$this->filesystem->exists($path)) {
|
||||
$this->filesystem->mkdir($path, 0755);
|
||||
}
|
||||
}
|
||||
|
||||
public function validateFileFormat(string $filePath, array $allowedExtensions): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
return in_array($extension, $allowedExtensions, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Shared\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\NotificationInterface;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
readonly class SymfonyNotification implements NotificationInterface
|
||||
{
|
||||
public function __construct(
|
||||
private HubInterface $hub
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendSuccess(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'success',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendError(string $message): void
|
||||
{
|
||||
$this->sendUpdate([
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendUpdate(array $data): void
|
||||
{
|
||||
$update = new Update(
|
||||
'notifications',
|
||||
json_encode($data)
|
||||
);
|
||||
|
||||
$this->hub->publish($update);
|
||||
}
|
||||
}
|
||||
95
tests/Domain/Manga/Adapter/InMemoryChapterRepository.php
Normal file
95
tests/Domain/Manga/Adapter/InMemoryChapterRepository.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Adapter;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
|
||||
class InMemoryChapterRepository implements ChapterRepositoryInterface
|
||||
{
|
||||
/** @var array<string, Chapter> */
|
||||
private array $chapters = [];
|
||||
|
||||
public function findById(string $id): ?Chapter
|
||||
{
|
||||
return $this->chapters[$id] ?? null;
|
||||
}
|
||||
|
||||
public function findVisibleById(string $id): ?Chapter
|
||||
{
|
||||
$chapter = $this->chapters[$id] ?? null;
|
||||
if ($chapter && $chapter->isVisible()) {
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter
|
||||
{
|
||||
foreach ($this->chapters as $chapter) {
|
||||
if ($chapter->getMangaId() === $mangaId && $chapter->getNumber() === $chapterNumber) {
|
||||
return $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function save(Chapter $chapter): void
|
||||
{
|
||||
$this->chapters[$chapter->getId()] = $chapter;
|
||||
}
|
||||
|
||||
public function delete(Chapter $chapter): void
|
||||
{
|
||||
unset($this->chapters[$chapter->getId()]);
|
||||
}
|
||||
|
||||
public function findByMangaIdAndVolume(string $mangaId, int $volume): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->chapters,
|
||||
fn (Chapter $chapter) => $chapter->getMangaId() === $mangaId && $chapter->getVolume() === $volume
|
||||
);
|
||||
}
|
||||
|
||||
public function findVisibleByMangaIdAndVolume(string $mangaId, int $volume): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->chapters,
|
||||
fn (Chapter $chapter) =>
|
||||
$chapter->getMangaId() === $mangaId &&
|
||||
$chapter->getVolume() === $volume &&
|
||||
$chapter->isVisible()
|
||||
);
|
||||
}
|
||||
|
||||
public function findVisibleWithCbzByMangaIdAndVolume(string $mangaId, int $volume): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->chapters,
|
||||
fn (Chapter $chapter) =>
|
||||
$chapter->getMangaId() === $mangaId &&
|
||||
$chapter->getVolume() === $volume &&
|
||||
$chapter->isVisible() &&
|
||||
$chapter->isAvailable()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chapters
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
return array_values($this->chapters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all chapters
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->chapters = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Adapter;
|
||||
|
||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
|
||||
class InMemoryChapterSynchronizationService implements ChapterSynchronizationServiceInterface
|
||||
{
|
||||
/** @var array<string, array> */
|
||||
private array $synchronizedChapters = [];
|
||||
|
||||
public function synchronizeChapters(Manga $manga): array
|
||||
{
|
||||
$this->synchronizedChapters[$manga->getId()->getValue()] = [
|
||||
'manga_id' => $manga->getId()->getValue(),
|
||||
'external_id' => $manga->getExternalId()?->getValue(),
|
||||
'synchronized_at' => new \DateTimeImmutable()
|
||||
];
|
||||
|
||||
// Retourne les IDs des chapitres synchronisés (simulation)
|
||||
return ['chapter-1', 'chapter-2'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array>
|
||||
*/
|
||||
public function getSynchronizedChapters(): array
|
||||
{
|
||||
return $this->synchronizedChapters;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->synchronizedChapters = [];
|
||||
}
|
||||
}
|
||||
@@ -128,13 +128,14 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
return null;
|
||||
}
|
||||
|
||||
public function saveChapter(Chapter $chapter): void
|
||||
public function saveChapter(Chapter $chapter): ChapterId
|
||||
{
|
||||
$this->savedChapters[] = $chapter;
|
||||
if (!isset($this->chapters[$chapter->getMangaId()])) {
|
||||
$this->chapters[$chapter->getMangaId()] = [];
|
||||
}
|
||||
$this->chapters[$chapter->getMangaId()][] = $chapter;
|
||||
return new ChapterId($chapter->getId());
|
||||
}
|
||||
|
||||
/** @return array<Chapter> */
|
||||
@@ -160,6 +161,11 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
$manga->getDescription()
|
||||
];
|
||||
|
||||
// Ajouter les slugs alternatifs aux champs de recherche
|
||||
foreach ($manga->getAlternativeSlugs() as $altSlug) {
|
||||
$searchableFields[] = $altSlug;
|
||||
}
|
||||
|
||||
foreach ($searchableFields as $field) {
|
||||
if (str_contains(strtolower($field), strtolower($query))) {
|
||||
return true;
|
||||
|
||||
91
tests/Domain/Manga/Adapter/InMemoryPathManager.php
Normal file
91
tests/Domain/Manga/Adapter/InMemoryPathManager.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Adapter;
|
||||
|
||||
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||
|
||||
class InMemoryPathManager implements MangaPathManagerInterface
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private array $files = [];
|
||||
|
||||
public function getMangaDirectory(string $mangaTitle, string $publicationYear): string
|
||||
{
|
||||
$dir = '/tmp/manga/' . $this->slugify($mangaTitle) . '_' . $publicationYear;
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
$dir = $this->getMangaDirectory($mangaTitle, $publicationYear) . '/volume_' . $volumeNumber;
|
||||
$this->ensureDirectory($dir);
|
||||
return $dir;
|
||||
}
|
||||
|
||||
public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string
|
||||
{
|
||||
$dir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber);
|
||||
return $dir . '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '_ch' . $chapterNumber . '.cbz';
|
||||
}
|
||||
|
||||
public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string
|
||||
{
|
||||
return $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber)
|
||||
. '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '.cbz';
|
||||
}
|
||||
|
||||
public function createCbzArchive(array $files, string $cbzPath): void
|
||||
{
|
||||
// For testing, just store the CBZ path
|
||||
$this->files[$cbzPath] = json_encode($files);
|
||||
}
|
||||
|
||||
public function moveFileTo(string $sourcePath, string $destinationPath): void
|
||||
{
|
||||
// In-memory: just copy content if source exists
|
||||
if (file_exists($sourcePath)) {
|
||||
$content = file_get_contents($sourcePath);
|
||||
$this->files[$destinationPath] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
public function fileExists(string $path): bool
|
||||
{
|
||||
return isset($this->files[$path]) || file_exists($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored files
|
||||
*/
|
||||
public function getFiles(): array
|
||||
{
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored files
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->files = [];
|
||||
}
|
||||
|
||||
private function slugify(string $text): string
|
||||
{
|
||||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||||
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
|
||||
$text = preg_replace('~[^-\w]+~', '', $text);
|
||||
$text = trim($text, '-');
|
||||
$text = preg_replace('~-+~', '-', $text);
|
||||
$text = strtolower($text);
|
||||
return $text ?: 'n-a';
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
mkdir($path, 0777, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
|
||||
use App\Tests\Shared\Adapter\InMemoryMessageBus;
|
||||
use App\Tests\Shared\Adapter\InMemoryEventDispatcher;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||
@@ -22,7 +22,7 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||
private InMemoryMangaRepository $repository;
|
||||
private InMemoryImageProcessor $imageProcessor;
|
||||
private CreateMangaFromMangadexHandler $handler;
|
||||
private InMemoryMessageBus $messageBus;
|
||||
private InMemoryEventDispatcher $eventDispatcher;
|
||||
protected function setUp(): void
|
||||
{
|
||||
$manga = new Manga(
|
||||
@@ -41,12 +41,12 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||
$this->provider = new InMemoryMangaProvider([$manga]);
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->imageProcessor = new InMemoryImageProcessor();
|
||||
$this->messageBus = new InMemoryMessageBus();
|
||||
$this->eventDispatcher = new InMemoryEventDispatcher();
|
||||
$this->handler = new CreateMangaFromMangadexHandler(
|
||||
$this->provider,
|
||||
$this->repository,
|
||||
$this->imageProcessor,
|
||||
$this->messageBus
|
||||
$this->eventDispatcher
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,4 +76,4 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,29 @@ namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
|
||||
use App\Domain\Manga\Domain\Exception\MangadexApiException;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangadexClient;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryChapterSynchronizationService;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FetchMangaChaptersHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangadexClient $mangadexClient;
|
||||
private InMemoryChapterSynchronizationService $chapterSynchronizationService;
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private FetchMangaChaptersHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangadexClient = new InMemoryMangadexClient();
|
||||
$this->chapterSynchronizationService = new InMemoryChapterSynchronizationService();
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->handler = new FetchMangaChaptersHandler(
|
||||
$this->mangadexClient,
|
||||
$this->mangaRepository
|
||||
$this->mangaRepository,
|
||||
$this->chapterSynchronizationService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,22 +48,12 @@ class FetchMangaChaptersHandlerTest extends TestCase
|
||||
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->mangadexClient->addFeed($externalId, [
|
||||
[
|
||||
'id' => 'chapter-1',
|
||||
'attributes' => [
|
||||
'chapter' => '1',
|
||||
'title' => 'Chapter 1',
|
||||
'volume' => '1',
|
||||
'translatedLanguage' => 'fr'
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
$command = new FetchMangaChapters($mangaId);
|
||||
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||
$this->handler->handle($command);
|
||||
|
||||
$this->assertCount(1, $this->mangaRepository->getSavedChapters());
|
||||
$synchronizedChapters = $this->chapterSynchronizationService->getSynchronizedChapters();
|
||||
$this->assertCount(1, $synchronizedChapters);
|
||||
$this->assertArrayHasKey($mangaId, $synchronizedChapters);
|
||||
}
|
||||
|
||||
public function testHandleWithNonExistingManga(): void
|
||||
@@ -72,7 +63,7 @@ class FetchMangaChaptersHandlerTest extends TestCase
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Manga not found');
|
||||
|
||||
$command = new FetchMangaChapters($mangaId);
|
||||
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
@@ -93,10 +84,10 @@ class FetchMangaChaptersHandlerTest extends TestCase
|
||||
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Manga has no external ID');
|
||||
$this->expectException(MangadexApiException::class);
|
||||
$this->expectExceptionMessage('Manga has no external_id');
|
||||
|
||||
$command = new FetchMangaChapters($mangaId);
|
||||
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportChapter;
|
||||
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ImportChapterHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryChapterRepository $chapterRepository;
|
||||
private InMemoryPathManager $pathManager;
|
||||
private ImportChapterHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->chapterRepository = new InMemoryChapterRepository();
|
||||
$this->pathManager = new InMemoryPathManager();
|
||||
$this->handler = new ImportChapterHandler(
|
||||
$this->mangaRepository,
|
||||
$this->chapterRepository,
|
||||
$this->pathManager
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_chapter_not_found(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportChapter(
|
||||
mangaId: $mangaId,
|
||||
chapterNumber: 1.5,
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(ChapterNotFoundException::class);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
public function test_it_updates_existing_chapter_with_new_cbz(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
// Create an existing chapter without CBZ
|
||||
$existingChapter = new Chapter(
|
||||
new ChapterId('chapter-123'),
|
||||
$mangaId,
|
||||
1.5,
|
||||
'Chapter 1.5',
|
||||
1,
|
||||
true,
|
||||
null
|
||||
);
|
||||
$this->chapterRepository->save($existingChapter);
|
||||
|
||||
// Import the same chapter with CBZ
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportChapter(
|
||||
mangaId: $mangaId,
|
||||
chapterNumber: 1.5,
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
|
||||
// Assert
|
||||
$chapters = $this->chapterRepository->getAll();
|
||||
$this->assertCount(1, $chapters); // Still only one chapter
|
||||
|
||||
$updatedChapter = $chapters[0];
|
||||
$this->assertEquals('chapter-123', $updatedChapter->getId());
|
||||
$this->assertEquals($mangaId, $updatedChapter->getMangaId());
|
||||
$this->assertEquals(1.5, $updatedChapter->getNumber());
|
||||
$this->assertEquals('Chapter 1.5', $updatedChapter->getTitle()); // Title preserved
|
||||
$this->assertEquals(1, $updatedChapter->getVolume()); // Volume preserved
|
||||
$this->assertTrue($updatedChapter->isVisible());
|
||||
$this->assertTrue($updatedChapter->isAvailable()); // Now has CBZ
|
||||
$this->assertStringContainsString('_vol1_ch1.5.cbz', $updatedChapter->getCbzPath());
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_manga_not_found(): void
|
||||
{
|
||||
// Arrange
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportChapter(
|
||||
mangaId: 'non-existent-manga',
|
||||
chapterNumber: 1.0,
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(MangaNotFoundException::class);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_file_is_not_valid_cbz(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$invalidBinary = 'This is not a CBZ file';
|
||||
$command = new ImportChapter(
|
||||
mangaId: $mangaId,
|
||||
chapterNumber: 1.0,
|
||||
fileBinary: $invalidBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The provided file is not a valid CBZ file');
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal valid CBZ (ZIP) binary for testing
|
||||
*/
|
||||
private function createValidCbzBinary(): string
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz');
|
||||
|
||||
// Delete the empty file created by tempnam
|
||||
unlink($tmpFile);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
// Create a new ZIP archive (avoid opening empty file)
|
||||
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create test CBZ file');
|
||||
}
|
||||
|
||||
// Add a dummy image file to the ZIP
|
||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||
$zip->close();
|
||||
|
||||
$binaryContent = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
|
||||
return $binaryContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Manga\Application\CommandHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Command\ImportVolume;
|
||||
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
|
||||
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
|
||||
use App\Domain\Manga\Domain\Model\Chapter;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ImportVolumeHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $mangaRepository;
|
||||
private InMemoryChapterRepository $chapterRepository;
|
||||
private InMemoryPathManager $pathManager;
|
||||
private ImportVolumeHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mangaRepository = new InMemoryMangaRepository();
|
||||
$this->chapterRepository = new InMemoryChapterRepository();
|
||||
$this->pathManager = new InMemoryPathManager();
|
||||
$this->handler = new ImportVolumeHandler(
|
||||
$this->mangaRepository,
|
||||
$this->chapterRepository,
|
||||
$this->pathManager
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_updates_all_chapters_in_volume(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$volumeNumber = 1;
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
// Create chapters in volume 1
|
||||
for ($i = 1; $i <= 3; $i++) {
|
||||
$chapter = new Chapter(
|
||||
new ChapterId("chapter-$i"),
|
||||
$mangaId,
|
||||
(float)$i,
|
||||
"Chapter $i",
|
||||
$volumeNumber,
|
||||
true,
|
||||
null
|
||||
);
|
||||
$this->chapterRepository->save($chapter);
|
||||
}
|
||||
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportVolume(
|
||||
mangaId: $mangaId,
|
||||
volumeNumber: $volumeNumber,
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
|
||||
// Assert
|
||||
$chapters = $this->chapterRepository->findByMangaIdAndVolume($mangaId, $volumeNumber);
|
||||
$this->assertCount(3, $chapters);
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$this->assertTrue($chapter->isAvailable());
|
||||
$this->assertStringContainsString('_vol' . $volumeNumber . '.cbz', $chapter->getCbzPath());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_manga_not_found(): void
|
||||
{
|
||||
// Arrange
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportVolume(
|
||||
mangaId: 'non-existent-manga',
|
||||
volumeNumber: 1,
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(MangaNotFoundException::class);
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_file_is_not_valid_cbz(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$invalidBinary = 'This is not a CBZ file';
|
||||
$command = new ImportVolume(
|
||||
mangaId: $mangaId,
|
||||
volumeNumber: 1,
|
||||
fileBinary: $invalidBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The provided file is not a valid CBZ file');
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
public function test_it_throws_exception_when_no_chapters_in_volume(): void
|
||||
{
|
||||
// Arrange
|
||||
$mangaId = 'manga-123';
|
||||
$manga = new Manga(
|
||||
new MangaId($mangaId),
|
||||
new MangaTitle('One Piece'),
|
||||
new MangaSlug('one-piece'),
|
||||
'Description',
|
||||
'Eiichiro Oda',
|
||||
1997,
|
||||
['action', 'adventure'],
|
||||
'ongoing'
|
||||
);
|
||||
$this->mangaRepository->save($manga);
|
||||
|
||||
$cbzBinary = $this->createValidCbzBinary();
|
||||
$command = new ImportVolume(
|
||||
mangaId: $mangaId,
|
||||
volumeNumber: 999, // Non-existent volume
|
||||
fileBinary: $cbzBinary
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('No chapters found');
|
||||
|
||||
// Act
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal valid CBZ (ZIP) binary for testing
|
||||
*/
|
||||
private function createValidCbzBinary(): string
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||
unlink($tmpFile);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create test CBZ file');
|
||||
}
|
||||
|
||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||
$zip->close();
|
||||
|
||||
$binaryContent = file_get_contents($tmpFile);
|
||||
unlink($tmpFile);
|
||||
|
||||
return $binaryContent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\Manga\Application\QueryHandler;
|
||||
|
||||
use App\Domain\Manga\Application\Query\FindMangaMatchByFilename;
|
||||
use App\Domain\Manga\Application\QueryHandler\FindMangaMatchByFilenameHandler;
|
||||
use App\Domain\Manga\Domain\Model\Manga;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\ImageUrls;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||
use App\Domain\Manga\Infrastructure\Service\FilenameAnalyzer;
|
||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FindMangaMatchByFilenameHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryMangaRepository $repository;
|
||||
private FilenameAnalyzer $filenameAnalyzer;
|
||||
private FindMangaMatchByFilenameHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryMangaRepository();
|
||||
$this->filenameAnalyzer = new FilenameAnalyzer();
|
||||
$this->handler = new FindMangaMatchByFilenameHandler(
|
||||
$this->filenameAnalyzer,
|
||||
$this->repository
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_finds_exact_match_by_title(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('one-piece_vol108_ch1094.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$this->assertCount(1, $response->matches);
|
||||
|
||||
$match = $response->matches[0];
|
||||
$this->assertEquals('123', $match->id);
|
||||
$this->assertEquals('One Piece', $match->title);
|
||||
$this->assertEquals('one-piece', $match->slug);
|
||||
$this->assertEquals(1094.0, $match->chapterNumber);
|
||||
$this->assertEquals(108, $match->volumeNumber);
|
||||
|
||||
// Vérifier aussi dans la réponse globale
|
||||
$this->assertEquals(1094.0, $response->chapterNumber);
|
||||
$this->assertEquals(108, $response->volumeNumber);
|
||||
$this->assertNotEmpty($response->possibleTitles);
|
||||
}
|
||||
|
||||
public function test_it_returns_empty_matches_when_no_manga_found(): void
|
||||
{
|
||||
// Given - no manga in repository
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('unknown-manga_vol1_ch1.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertFalse($response->hasMatches());
|
||||
$this->assertCount(0, $response->matches);
|
||||
$this->assertNull($response->getBestMatch());
|
||||
}
|
||||
|
||||
public function test_it_finds_multiple_matches_and_sorts_by_score(): void
|
||||
{
|
||||
// Given
|
||||
$manga1 = $this->createManga(
|
||||
id: '1',
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece'
|
||||
);
|
||||
$manga2 = $this->createManga(
|
||||
id: '2',
|
||||
title: 'One Piece Z',
|
||||
slug: 'one-piece-z'
|
||||
);
|
||||
$manga3 = $this->createManga(
|
||||
id: '3',
|
||||
title: 'The One Piece',
|
||||
slug: 'the-one-piece'
|
||||
);
|
||||
|
||||
$this->repository->save($manga1);
|
||||
$this->repository->save($manga2);
|
||||
$this->repository->save($manga3);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('one-piece_vol108_ch1094.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$this->assertGreaterThanOrEqual(1, count($response->matches));
|
||||
|
||||
// Le meilleur match devrait être "One Piece" (correspondance exacte)
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertNotNull($bestMatch);
|
||||
$this->assertEquals('One Piece', $bestMatch->title);
|
||||
|
||||
// Les scores doivent être triés par ordre décroissant
|
||||
$scores = array_map(fn($match) => $match->matchScore, $response->matches);
|
||||
$sortedScores = $scores;
|
||||
rsort($sortedScores);
|
||||
$this->assertEquals($sortedScores, $scores);
|
||||
}
|
||||
|
||||
public function test_it_extracts_chapter_and_volume_numbers(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Attack on Titan',
|
||||
slug: 'attack-on-titan'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('attack-on-titan_vol32_ch130.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertEquals(130.0, $response->chapterNumber);
|
||||
$this->assertEquals(32, $response->volumeNumber);
|
||||
|
||||
// Vérifier que chaque match contient aussi ces informations
|
||||
foreach ($response->matches as $match) {
|
||||
$this->assertEquals(130.0, $match->chapterNumber);
|
||||
$this->assertEquals(32, $match->volumeNumber);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_decimal_chapter_numbers(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Naruto',
|
||||
slug: 'naruto'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('naruto_vol50_ch456.5.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertEquals(456.5, $response->chapterNumber);
|
||||
$this->assertEquals(50, $response->volumeNumber);
|
||||
|
||||
// Vérifier que chaque match contient aussi ces informations
|
||||
foreach ($response->matches as $match) {
|
||||
$this->assertEquals(456.5, $match->chapterNumber);
|
||||
$this->assertEquals(50, $match->volumeNumber);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_finds_matches_with_alternative_slugs(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Shingeki no Kyojin',
|
||||
slug: 'shingeki-no-kyojin',
|
||||
alternativeSlugs: ['attack-on-titan', 'aot']
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('attack-on-titan_vol1_ch1.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertNotNull($bestMatch);
|
||||
$this->assertEquals('123', $bestMatch->id);
|
||||
$this->assertEquals('Shingeki no Kyojin', $bestMatch->title);
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_without_chapter_or_volume(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('one-piece.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$this->assertNull($response->chapterNumber);
|
||||
$this->assertNull($response->volumeNumber);
|
||||
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertEquals('123', $bestMatch->id);
|
||||
$this->assertNull($bestMatch->chapterNumber);
|
||||
$this->assertNull($bestMatch->volumeNumber);
|
||||
}
|
||||
|
||||
public function test_it_finds_single_match_with_unique_id(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('one-piece_vol1_ch1.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then - Vérifier qu'on a bien un seul résultat avec l'ID correct
|
||||
$this->assertCount(1, $response->matches);
|
||||
$this->assertEquals('123', $response->matches[0]->id);
|
||||
}
|
||||
|
||||
public function test_it_provides_possible_titles_in_response(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Dragon Ball',
|
||||
slug: 'dragon-ball'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename('dragon-ball_vol1_ch5.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertNotEmpty($response->possibleTitles);
|
||||
$this->assertEquals(['dragon-ball'], $response->possibleTitles);
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_volume(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'One Piece',
|
||||
slug: 'one-piece'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When - Fichier avec seulement un volume, sans chapitre
|
||||
$query = new FindMangaMatchByFilename('one-piece_vol108.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$this->assertEquals(108, $response->volumeNumber);
|
||||
$this->assertNull($response->chapterNumber);
|
||||
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertNotNull($bestMatch);
|
||||
$this->assertEquals('123', $bestMatch->id);
|
||||
$this->assertEquals(108, $bestMatch->volumeNumber);
|
||||
$this->assertNull($bestMatch->chapterNumber);
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_chapter(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Naruto',
|
||||
slug: 'naruto'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
// When - Fichier avec seulement un chapitre, sans volume
|
||||
$query = new FindMangaMatchByFilename('naruto_ch456.cbz');
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches());
|
||||
$this->assertEquals(456.0, $response->chapterNumber);
|
||||
$this->assertNull($response->volumeNumber);
|
||||
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertNotNull($bestMatch);
|
||||
$this->assertEquals('123', $bestMatch->id);
|
||||
$this->assertEquals(456.0, $bestMatch->chapterNumber);
|
||||
$this->assertNull($bestMatch->volumeNumber);
|
||||
}
|
||||
|
||||
public function test_it_handles_various_volume_only_formats(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'Attack on Titan',
|
||||
slug: 'attack-on-titan'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
$testCases = [
|
||||
['filename' => 'attack-on-titan vol 32.cbz', 'expectedVolume' => 32],
|
||||
['filename' => 'attack-on-titan-tome-15.cbz', 'expectedVolume' => 15],
|
||||
['filename' => 'attack-on-titan_t10.cbz', 'expectedVolume' => 10],
|
||||
['filename' => 'attack-on-titan Volume 5.cbr', 'expectedVolume' => 5],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename($case['filename']);
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches(), "Should find match for {$case['filename']}");
|
||||
$this->assertEquals($case['expectedVolume'], $response->volumeNumber,
|
||||
"Failed volume extraction for: {$case['filename']}");
|
||||
$this->assertNull($response->chapterNumber,
|
||||
"Should not have chapter for: {$case['filename']}");
|
||||
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertEquals($case['expectedVolume'], $bestMatch->volumeNumber,
|
||||
"Match should have correct volume for: {$case['filename']}");
|
||||
$this->assertNull($bestMatch->chapterNumber,
|
||||
"Match should not have chapter for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_various_chapter_only_formats(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga(
|
||||
id: '123',
|
||||
title: 'My Hero Academia',
|
||||
slug: 'my-hero-academia'
|
||||
);
|
||||
$this->repository->save($manga);
|
||||
|
||||
$testCases = [
|
||||
['filename' => 'my-hero-academia ch 150.cbz', 'expectedChapter' => 150.0],
|
||||
['filename' => 'my-hero-academia-chap-200.cbz', 'expectedChapter' => 200.0],
|
||||
['filename' => 'my-hero-academia_chapter_75.cbz', 'expectedChapter' => 75.0],
|
||||
['filename' => 'my-hero-academia chapitre 100.cbr', 'expectedChapter' => 100.0],
|
||||
['filename' => 'my-hero-academia_ch99.5.cbz', 'expectedChapter' => 99.5],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
// When
|
||||
$query = new FindMangaMatchByFilename($case['filename']);
|
||||
$response = $this->handler->handle($query);
|
||||
|
||||
// Then
|
||||
$this->assertTrue($response->hasMatches(), "Should find match for {$case['filename']}");
|
||||
$this->assertEquals($case['expectedChapter'], $response->chapterNumber,
|
||||
"Failed chapter extraction for: {$case['filename']}");
|
||||
$this->assertNull($response->volumeNumber,
|
||||
"Should not have volume for: {$case['filename']}");
|
||||
|
||||
$bestMatch = $response->getBestMatch();
|
||||
$this->assertEquals($case['expectedChapter'], $bestMatch->chapterNumber,
|
||||
"Match should have correct chapter for: {$case['filename']}");
|
||||
$this->assertNull($bestMatch->volumeNumber,
|
||||
"Match should not have volume for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
private function createManga(
|
||||
string $id,
|
||||
string $title,
|
||||
string $slug,
|
||||
array $alternativeSlugs = [],
|
||||
?string $thumbnailUrl = null
|
||||
): Manga {
|
||||
return new Manga(
|
||||
id: new MangaId($id),
|
||||
title: new MangaTitle($title),
|
||||
slug: new MangaSlug($slug),
|
||||
description: 'Test description',
|
||||
author: 'Test Author',
|
||||
publicationYear: 2000,
|
||||
genres: ['action'],
|
||||
status: 'ongoing',
|
||||
imageUrls: new ImageUrls(
|
||||
'http://example.com/full.jpg',
|
||||
$thumbnailUrl ?? 'http://example.com/thumbnail.jpg'
|
||||
),
|
||||
alternativeSlugs: $alternativeSlugs
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->repository->clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Domain\Manga\Infrastructure\Service;
|
||||
|
||||
use App\Domain\Manga\Infrastructure\Service\FilenameAnalyzer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FilenameAnalyzerTest extends TestCase
|
||||
{
|
||||
private FilenameAnalyzer $analyzer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->analyzer = new FilenameAnalyzer();
|
||||
}
|
||||
|
||||
public function test_it_analyzes_one_piece_filename_correctly(): void
|
||||
{
|
||||
// Given
|
||||
$filename = 'one-piece_vol108_ch1094.cbz';
|
||||
|
||||
// When
|
||||
$result = $this->analyzer->analyze($filename);
|
||||
|
||||
// Then
|
||||
$this->assertEquals('one-piece', $result->getTitle()->getValue());
|
||||
$this->assertEquals(1094.0, $result->getChapterNumber()->getValue());
|
||||
$this->assertEquals(108.0, $result->getVolumeNumber()->getValue());
|
||||
$this->assertTrue($result->hasChapterNumber());
|
||||
$this->assertTrue($result->hasVolumeNumber());
|
||||
}
|
||||
|
||||
public function test_it_handles_different_filename_formats(): void
|
||||
{
|
||||
$testCases = [
|
||||
// Format underscore
|
||||
[
|
||||
'filename' => 'attack-on-titan_vol32_ch130.cbz',
|
||||
'expectedTitle' => 'attack-on-titan',
|
||||
'expectedChapter' => 130.0,
|
||||
'expectedVolume' => 32.0,
|
||||
],
|
||||
// Format avec espaces
|
||||
[
|
||||
'filename' => 'Dragon Ball vol 1 ch 5.cbz',
|
||||
'expectedTitle' => 'Dragon Ball',
|
||||
'expectedChapter' => 5.0,
|
||||
'expectedVolume' => 1.0,
|
||||
],
|
||||
// Format avec tirets
|
||||
[
|
||||
'filename' => 'my-hero-academia-vol15-ch150.cbr',
|
||||
'expectedTitle' => 'my-hero-academia',
|
||||
'expectedChapter' => 150.0,
|
||||
'expectedVolume' => 15.0,
|
||||
],
|
||||
// Format chapitre décimal
|
||||
[
|
||||
'filename' => 'naruto_vol50_ch456.5.cbz',
|
||||
'expectedTitle' => 'naruto',
|
||||
'expectedChapter' => 456.5,
|
||||
'expectedVolume' => 50.0,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
|
||||
"Failed for filename: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
|
||||
"Failed chapter extraction for: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
|
||||
"Failed volume extraction for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_extracts_and_cleans_title(): void
|
||||
{
|
||||
// Given
|
||||
$filename = 'one-piece_vol108_ch1094.cbz';
|
||||
|
||||
// When
|
||||
$result = $this->analyzer->analyze($filename);
|
||||
|
||||
// Then - should extract and clean the title
|
||||
$this->assertEquals('one-piece', $result->getTitle()->getValue());
|
||||
$this->assertNotEmpty($result->getTitle()->getValue(), 'Title should not be empty');
|
||||
}
|
||||
|
||||
public function test_it_handles_files_without_volume_or_chapter(): void
|
||||
{
|
||||
$testCases = [
|
||||
[
|
||||
'filename' => 'one-piece.cbz',
|
||||
'expectedTitle' => 'one-piece',
|
||||
],
|
||||
[
|
||||
'filename' => 'manga_title_only.cbr',
|
||||
'expectedTitle' => 'manga_title_only',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue());
|
||||
$this->assertFalse($result->hasChapterNumber());
|
||||
$this->assertFalse($result->hasVolumeNumber());
|
||||
$this->assertNull($result->getChapterNumber());
|
||||
$this->assertNull($result->getVolumeNumber());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_cbz_and_cbr_extensions(): void
|
||||
{
|
||||
// Given
|
||||
$testCases = [
|
||||
['filename' => 'one-piece.cbz', 'expectedTitle' => 'one-piece'],
|
||||
['filename' => 'manga.cbr', 'expectedTitle' => 'manga'],
|
||||
['filename' => 'test.CBZ', 'expectedTitle' => 'test'],
|
||||
['filename' => 'test.CBR', 'expectedTitle' => 'test'],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
// When
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
// Then - L'extension est enlevée et le titre est extrait
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_cleans_common_patterns(): void
|
||||
{
|
||||
$testCases = [
|
||||
[
|
||||
'filename' => 'one-piece-scan-fr_vol108_ch1094.cbz',
|
||||
'cleanedTitle' => 'one-piece',
|
||||
],
|
||||
[
|
||||
'filename' => 'manga-raw-jp_vol1_ch1.cbz',
|
||||
'cleanedTitle' => 'manga',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
$title = $result->getTitle()->getValue();
|
||||
|
||||
// Vérifie que le titre est nettoyé
|
||||
$this->assertEquals($case['cleanedTitle'], $title,
|
||||
"Title should be cleaned for {$case['filename']}");
|
||||
|
||||
// Vérifie que le titre nettoyé ne contient pas les mots indésirables
|
||||
$this->assertDoesNotMatchRegularExpression('/\b(?:scan|raw|fr|en|jp|hq|lq)\b/i', $title,
|
||||
"Cleaned title should not contain unwanted patterns for {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_volume(): void
|
||||
{
|
||||
$testCases = [
|
||||
[
|
||||
'filename' => 'one-piece_vol108.cbz',
|
||||
'expectedTitle' => 'one-piece',
|
||||
'expectedVolume' => 108.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'attack-on-titan vol 32.cbz',
|
||||
'expectedTitle' => 'attack-on-titan',
|
||||
'expectedVolume' => 32.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'naruto-tome-50.cbz',
|
||||
'expectedTitle' => 'naruto',
|
||||
'expectedVolume' => 50.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'bleach_t15.cbz',
|
||||
'expectedTitle' => 'bleach',
|
||||
'expectedVolume' => 15.0,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
|
||||
"Failed title extraction for: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
|
||||
"Failed volume extraction for: {$case['filename']}");
|
||||
$this->assertFalse($result->hasChapterNumber(),
|
||||
"Should not have chapter for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_chapter(): void
|
||||
{
|
||||
$testCases = [
|
||||
[
|
||||
'filename' => 'naruto_ch456.cbz',
|
||||
'expectedTitle' => 'naruto',
|
||||
'expectedChapter' => 456.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'my-hero-academia ch 150.cbz',
|
||||
'expectedTitle' => 'my-hero-academia',
|
||||
'expectedChapter' => 150.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'bleach-chap-200.cbz',
|
||||
'expectedTitle' => 'bleach',
|
||||
'expectedChapter' => 200.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'one-piece_chapter_1094.cbz',
|
||||
'expectedTitle' => 'one-piece',
|
||||
'expectedChapter' => 1094.0,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
|
||||
"Failed title extraction for: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
|
||||
"Failed chapter extraction for: {$case['filename']}");
|
||||
$this->assertFalse($result->hasVolumeNumber(),
|
||||
"Should not have volume for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_it_handles_full_dash_patterns(): void
|
||||
{
|
||||
$testCases = [
|
||||
[
|
||||
'filename' => 'berserk-volume-42.cbz',
|
||||
'expectedTitle' => 'berserk',
|
||||
'expectedVolume' => 42.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'berserk-chapter-100.cbz',
|
||||
'expectedTitle' => 'berserk',
|
||||
'expectedChapter' => 100.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'berserk-chapitre-100.cbz',
|
||||
'expectedTitle' => 'berserk',
|
||||
'expectedChapter' => 100.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'berserk-volume-42-chapter-100.cbz',
|
||||
'expectedTitle' => 'berserk',
|
||||
'expectedVolume' => 42.0,
|
||||
'expectedChapter' => 100.0,
|
||||
],
|
||||
[
|
||||
'filename' => 'berserk-chapitre-100-volume-42.cbz',
|
||||
'expectedTitle' => 'berserk',
|
||||
'expectedVolume' => 42.0,
|
||||
'expectedChapter' => 100.0,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($testCases as $case) {
|
||||
$result = $this->analyzer->analyze($case['filename']);
|
||||
|
||||
$this->assertEquals($case['expectedTitle'], $result->getTitle()->getValue(),
|
||||
"Failed title extraction for: {$case['filename']}");
|
||||
|
||||
if (isset($case['expectedVolume'])) {
|
||||
$this->assertTrue($result->hasVolumeNumber(),
|
||||
"Should have volume for: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedVolume'], $result->getVolumeNumber()->getValue(),
|
||||
"Failed volume extraction for: {$case['filename']}");
|
||||
} else {
|
||||
$this->assertFalse($result->hasVolumeNumber(),
|
||||
"Should not have volume for: {$case['filename']}");
|
||||
}
|
||||
|
||||
if (isset($case['expectedChapter'])) {
|
||||
$this->assertTrue($result->hasChapterNumber(),
|
||||
"Should have chapter for: {$case['filename']}");
|
||||
$this->assertEquals($case['expectedChapter'], $result->getChapterNumber()->getValue(),
|
||||
"Failed chapter extraction for: {$case['filename']}");
|
||||
} else {
|
||||
$this->assertFalse($result->hasChapterNumber(),
|
||||
"Should not have chapter for: {$case['filename']}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
'A test manga description',
|
||||
'Test Author',
|
||||
'2024',
|
||||
[] // Pas de sources préférées par défaut
|
||||
false, // monitored
|
||||
[], // preferredSources
|
||||
[] // alternativeSlugs
|
||||
);
|
||||
|
||||
// Ajoute un manga avec des sources préférées pour les tests
|
||||
@@ -30,7 +32,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
'A test manga with preferred sources',
|
||||
'Test Author',
|
||||
'2024',
|
||||
['test-source'] // Une source préférée
|
||||
false, // monitored
|
||||
['test-source'], // preferredSources
|
||||
[] // alternativeSlugs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +59,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
||||
$manga->getDescription(),
|
||||
$manga->getAuthor(),
|
||||
$manga->getPublicationYear(),
|
||||
$sourceIds // Mise à jour des sources préférées
|
||||
$manga->isMonitored(), // monitored
|
||||
$sourceIds, // preferredSources
|
||||
$manga->getAlternativeSlugs() // alternativeSlugs
|
||||
);
|
||||
$this->mangas[$mangaId] = $updatedManga;
|
||||
}
|
||||
|
||||
60
tests/Domain/Scraping/Adapter/InMemoryScraperFactory.php
Normal file
60
tests/Domain/Scraping/Adapter/InMemoryScraperFactory.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Domain\Scraping\Adapter;
|
||||
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperFactoryInterface;
|
||||
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
|
||||
|
||||
class InMemoryScraperFactory implements ScraperFactoryInterface
|
||||
{
|
||||
/** @var array<string, ScraperInterface> */
|
||||
private array $scrapers = [];
|
||||
|
||||
public function createScraper(string $source): ScraperInterface
|
||||
{
|
||||
if (!isset($this->scrapers[$source])) {
|
||||
$this->scrapers[$source] = new InMemoryScraperAdapter();
|
||||
}
|
||||
|
||||
return $this->scrapers[$source];
|
||||
}
|
||||
|
||||
public function getBestScraper(): ScraperInterface
|
||||
{
|
||||
return $this->createScraper('best');
|
||||
}
|
||||
|
||||
public function getFallbackScraper(): ScraperInterface
|
||||
{
|
||||
return $this->createScraper('fallback');
|
||||
}
|
||||
|
||||
public function getScraperWithFallback(string $preferredType): ScraperInterface
|
||||
{
|
||||
if (isset($this->scrapers[$preferredType])) {
|
||||
return $this->scrapers[$preferredType];
|
||||
}
|
||||
|
||||
return $this->getFallbackScraper();
|
||||
}
|
||||
|
||||
public function getSupportedTypes(): array
|
||||
{
|
||||
return array_keys($this->scrapers);
|
||||
}
|
||||
|
||||
public function isSupported(string $type): bool
|
||||
{
|
||||
return isset($this->scrapers[$type]);
|
||||
}
|
||||
|
||||
public function addScraper(string $source, ScraperInterface $scraper): void
|
||||
{
|
||||
$this->scrapers[$source] = $scraper;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->scrapers = [];
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperFactory;
|
||||
use App\Tests\Domain\Scraping\Adapter\InMemorySourceRepository;
|
||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -23,7 +23,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
|
||||
class ScrapeChapterHandlerTest extends TestCase
|
||||
{
|
||||
private InMemoryScraperAdapter $scraper;
|
||||
private InMemoryScraperFactory $scraperFactory;
|
||||
private InMemoryImageDownloader $imageDownloader;
|
||||
private InMemoryCbzGenerator $cbzGenerator;
|
||||
private InMemoryJobRepository $jobRepository;
|
||||
@@ -36,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->scraper = new InMemoryScraperAdapter();
|
||||
$this->scraperFactory = new InMemoryScraperFactory();
|
||||
$this->imageDownloader = new InMemoryImageDownloader();
|
||||
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
|
||||
$this->jobRepository = new InMemoryJobRepository();
|
||||
@@ -59,7 +59,7 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
));
|
||||
|
||||
$this->handler = new ScrapeChapterHandler(
|
||||
$this->scraper,
|
||||
$this->scraperFactory,
|
||||
$this->imageDownloader,
|
||||
$this->cbzGenerator,
|
||||
$this->jobRepository,
|
||||
@@ -92,31 +92,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
||||
$this->assertNotNull($chapter->cbzPath);
|
||||
}
|
||||
|
||||
public function testHandleThrowsException(): void
|
||||
{
|
||||
$command = new ScrapeChapter(
|
||||
chapterId: '1'
|
||||
);
|
||||
|
||||
$exception = new \Exception('Scraping failed');
|
||||
$this->scraper->simulateError($exception);
|
||||
|
||||
$this->handler->handle($command);
|
||||
|
||||
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
|
||||
$this->assertCount(1, $dispatchedMessages);
|
||||
$this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[0]);
|
||||
$this->assertEquals('test-manga', $dispatchedMessages[0]->getMangaId());
|
||||
$this->assertEquals('2', $dispatchedMessages[0]->getChapterNumber());
|
||||
$this->assertEquals('Scraping failed', $dispatchedMessages[0]->getReason());
|
||||
|
||||
$jobs = $this->jobRepository->findByType('scraping_job');
|
||||
$job = array_values($jobs)[0];
|
||||
$this->assertCount(1, $jobs);
|
||||
$this->assertEquals(JobStatus::FAILED, $job->status);
|
||||
$this->assertEquals('Scraping failed', $job->failureReason);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->jobRepository->clear();
|
||||
|
||||
@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
||||
'manga' => $manga,
|
||||
'volume' => 1,
|
||||
'visible' => true,
|
||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
||||
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||
]);
|
||||
|
||||
$mangaId = $manga->getId();
|
||||
@@ -40,7 +40,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
||||
$this->assertResponseHeaderSame('Content-Type', 'application/x-cbz');
|
||||
$contentDisposition = static::getClient()->getResponse()->headers->get('Content-Disposition');
|
||||
$this->assertStringContainsString('attachment; filename=', $contentDisposition);
|
||||
$this->assertStringContainsString('one-piece-volume-1.cbz', $contentDisposition);
|
||||
$this->assertStringContainsString('one-piece_vol1.cbz', $contentDisposition);
|
||||
}
|
||||
|
||||
public function test_it_returns_404_when_manga_not_found(): void
|
||||
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
||||
'volume' => 1,
|
||||
'number' => 1.0,
|
||||
'visible' => true,
|
||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
||||
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||
]);
|
||||
|
||||
ChapterFactory::createOne([
|
||||
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
||||
'volume' => 1,
|
||||
'number' => 2.0,
|
||||
'visible' => false, // Soft deleted
|
||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
||||
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||
]);
|
||||
|
||||
ChapterFactory::createOne([
|
||||
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
||||
'volume' => 1,
|
||||
'number' => 4.0,
|
||||
'visible' => true,
|
||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
||||
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||
]);
|
||||
|
||||
$mangaId = $manga->getId();
|
||||
|
||||
@@ -58,7 +58,7 @@ class FetchMangaChaptersTest extends AbstractApiTestCase
|
||||
$messages = $this->messageBus->getDispatchedMessages();
|
||||
$this->assertCount(1, $messages);
|
||||
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
|
||||
$this->assertEquals($mangaId, $messages[0]->mangaId);
|
||||
$this->assertEquals(new MangaId($mangaId), $messages[0]->mangaId);
|
||||
}
|
||||
|
||||
public function testFetchChaptersWithInvalidMangaId(): void
|
||||
|
||||
236
tests/Feature/Manga/FindMangaMatchByFilenameTest.php
Normal file
236
tests/Feature/Manga/FindMangaMatchByFilenameTest.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Feature\Manga;
|
||||
|
||||
use App\Entity\Manga;
|
||||
use App\Tests\Feature\AbstractApiTestCase;
|
||||
use Zenstruck\Foundry\Test\ResetDatabase;
|
||||
|
||||
class FindMangaMatchByFilenameTest extends AbstractApiTestCase
|
||||
{
|
||||
use ResetDatabase;
|
||||
|
||||
public function test_it_finds_exact_match_by_filename(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('One Piece', 'one-piece');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'one-piece_vol108_ch1094.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertArrayHasKey('matches', $data);
|
||||
|
||||
$this->assertCount(1, $data['matches']);
|
||||
$this->assertEquals('One Piece', $data['matches'][0]['title']);
|
||||
$this->assertEquals('one-piece', $data['matches'][0]['slug']);
|
||||
$this->assertEquals(1094.0, $data['matches'][0]['chapterNumber']);
|
||||
$this->assertEquals(108, $data['matches'][0]['volumeNumber']);
|
||||
$this->assertGreaterThan(0, $data['matches'][0]['matchScore']);
|
||||
|
||||
}
|
||||
|
||||
public function test_it_returns_empty_matches_when_no_manga_found(): void
|
||||
{
|
||||
// Given - no manga in database
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'unknown-manga_vol1_ch1.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertArrayHasKey('matches', $data);
|
||||
$this->assertCount(0, $data['matches']);
|
||||
}
|
||||
|
||||
public function test_it_returns_bad_request_when_filename_is_missing(): void
|
||||
{
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/api/manga-matches');
|
||||
|
||||
// Then
|
||||
$this->assertResponseStatusCodeSame(400);
|
||||
}
|
||||
|
||||
public function test_it_extracts_chapter_and_volume_correctly(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('Attack on Titan', 'attack-on-titan');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'attack-on-titan_vol32_ch130.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertNotEmpty($data['matches']);
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_volume(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('Naruto', 'naruto');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'naruto_vol50.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertNotEmpty($data['matches']);
|
||||
$this->assertEquals('Naruto', $data['matches'][0]['title']);
|
||||
}
|
||||
|
||||
public function test_it_handles_filename_with_only_chapter(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('Bleach', 'bleach');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'bleach_ch200.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertNotEmpty($data['matches']);
|
||||
$this->assertEquals('Bleach', $data['matches'][0]['title']);
|
||||
}
|
||||
|
||||
public function test_it_sorts_matches_by_score(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('One Piece', 'one-piece');
|
||||
$this->createManga('One Piece Z', 'one-piece-z');
|
||||
$this->createManga('The One Piece', 'the-one-piece');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'one-piece_vol108_ch1094.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertArrayHasKey('matches', $data);
|
||||
$this->assertGreaterThanOrEqual(1, count($data['matches']));
|
||||
|
||||
// Le premier résultat devrait être "One Piece" (meilleure correspondance)
|
||||
$this->assertEquals('One Piece', $data['matches'][0]['title']);
|
||||
|
||||
// Vérifier que les scores sont triés par ordre décroissant
|
||||
$scores = array_map(fn($match) => $match['matchScore'], $data['matches']);
|
||||
$sortedScores = $scores;
|
||||
rsort($sortedScores);
|
||||
$this->assertEquals($sortedScores, $scores);
|
||||
}
|
||||
|
||||
public function test_it_handles_alternative_slugs(): void
|
||||
{
|
||||
// Given
|
||||
$manga = $this->createManga('Shingeki no Kyojin', 'shingeki-no-kyojin');
|
||||
$manga->setAlternativeSlugs(['attack-on-titan', 'aot']);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'attack-on-titan_vol1_ch1.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertArrayHasKey('matches', $data);
|
||||
$this->assertNotEmpty($data['matches']);
|
||||
$this->assertEquals('Shingeki no Kyojin', $data['matches'][0]['title']);
|
||||
$this->assertContains('attack-on-titan', $data['matches'][0]['alternativeSlugs']);
|
||||
}
|
||||
|
||||
public function test_it_provides_possible_titles_variants(): void
|
||||
{
|
||||
// Given
|
||||
$this->createManga('Dragon Ball', 'dragon-ball');
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/manga-matches', [
|
||||
'query' => [
|
||||
'filename' => 'dragon-ball_vol1_ch5.cbz'
|
||||
]
|
||||
]);
|
||||
|
||||
// Then
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
|
||||
$this->assertNotEmpty($data['matches']);
|
||||
// Vérifier que le match a bien le slug 'dragon-ball'
|
||||
$this->assertEquals('dragon-ball', $data['matches'][0]['slug']);
|
||||
}
|
||||
|
||||
private function createManga(string $title, string $slug): Manga
|
||||
{
|
||||
$manga = new Manga();
|
||||
$manga->setTitle($title)
|
||||
->setSlug($slug)
|
||||
->setDescription('Description test')
|
||||
->setAuthor('Author test')
|
||||
->setPublicationYear(2020)
|
||||
->setGenres(['action'])
|
||||
->setStatus('ongoing')
|
||||
->setRating(4.5)
|
||||
->setMonitored(false)
|
||||
->setImageUrl('https://via.placeholder.com/150')
|
||||
->setThumbnailUrl('https://via.placeholder.com/150')
|
||||
->setCreatedAt(new \DateTimeImmutable('2020-01-01'));
|
||||
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $manga;
|
||||
}
|
||||
}
|
||||
|
||||
131
tests/Feature/Manga/ImportChapterTest.php
Normal file
131
tests/Feature/Manga/ImportChapterTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Feature\Manga;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImportChapterTest extends WebTestCase
|
||||
{
|
||||
private const API_ENDPOINT = '/api/chapters/import';
|
||||
|
||||
public function test_it_returns_404_when_manga_not_found(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'non-existent-manga-id',
|
||||
'chapterNumber' => '1.5'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertEquals('Manga not found', $response['error']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_manga_id_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'chapterNumber' => '1.5'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('mangaId is required', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_chapter_number_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('chapterNumber is required', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_file_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id',
|
||||
'chapterNumber' => '1.5'
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('Please upload a file', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_file_is_not_cbz(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Create a non-CBZ file
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($tempFile, 'This is not a CBZ file');
|
||||
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id',
|
||||
'chapterNumber' => '1.5'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('valid CBZ file', $response[0]['message']);
|
||||
}
|
||||
|
||||
private function createValidCbzFile(): UploadedFile
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||
unlink($tmpFile);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create test CBZ file');
|
||||
}
|
||||
|
||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($tmpFile, 'test-chapter.cbz', 'application/x-cbz');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
131
tests/Feature/Manga/ImportVolumeTest.php
Normal file
131
tests/Feature/Manga/ImportVolumeTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Feature\Manga;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImportVolumeTest extends WebTestCase
|
||||
{
|
||||
private const API_ENDPOINT = '/api/volumes/import';
|
||||
|
||||
public function test_it_returns_404_when_manga_not_found(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'non-existent-manga-id',
|
||||
'volumeNumber' => '1'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertEquals('Manga not found', $response['error']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_manga_id_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'volumeNumber' => '1'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('mangaId is required', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_volume_number_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$file = $this->createValidCbzFile();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('volumeNumber is required', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_file_is_missing(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id',
|
||||
'volumeNumber' => '1'
|
||||
]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('Please upload a file', $response[0]['message']);
|
||||
}
|
||||
|
||||
public function test_it_returns_422_when_file_is_not_cbz(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Create a non-CBZ file
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
||||
file_put_contents($tempFile, 'This is not a CBZ file');
|
||||
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
self::API_ENDPOINT,
|
||||
[
|
||||
'mangaId' => 'some-manga-id',
|
||||
'volumeNumber' => '1'
|
||||
],
|
||||
['file' => $file]
|
||||
);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$response = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertStringContainsString('valid CBZ file', $response[0]['message']);
|
||||
}
|
||||
|
||||
private function createValidCbzFile(): UploadedFile
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'cbz_');
|
||||
unlink($tmpFile);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new \RuntimeException('Cannot create test CBZ file');
|
||||
}
|
||||
|
||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($tmpFile, 'test-volume.cbz', 'application/x-cbz');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
||||
{
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/mangas/search', [
|
||||
$response = $client->request('GET', '/api/manga-search', [
|
||||
'query' => [
|
||||
'q' => ''
|
||||
]
|
||||
@@ -32,7 +32,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
||||
{
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/mangas/search', [
|
||||
$response = $client->request('GET', '/api/manga-search', [
|
||||
'query' => [
|
||||
'q' => 'on'
|
||||
]
|
||||
@@ -55,7 +55,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/mangas/search', [
|
||||
$response = $client->request('GET', '/api/manga-search', [
|
||||
'query' => [
|
||||
'q' => 'one'
|
||||
]
|
||||
@@ -81,7 +81,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
||||
|
||||
// When
|
||||
$client = static::createClient();
|
||||
$response = $client->request('GET', '/api/mangas/search', [
|
||||
$response = $client->request('GET', '/api/manga-search', [
|
||||
'query' => [
|
||||
'q' => 'dragon'
|
||||
]
|
||||
@@ -141,4 +141,4 @@ class SearchMangaTest extends AbstractApiTestCase
|
||||
$this->entityManager->persist($manga);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user