feat: analyse import + all tests fixed
This commit is contained in:
parent
fbe9619224
commit
3170a7c60e
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
|
<NotificationToast />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||||
name: 'App'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -18,4 +17,4 @@ export default {
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
195
assets/vue/app/domain/import/README.md
Normal file
195
assets/vue/app/domain/import/README.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# 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/import/upload-file
|
||||||
|
```
|
||||||
|
FormData :
|
||||||
|
- `file`: Le fichier CBZ/CBR
|
||||||
|
- `mangaId`: ID du manga sélectionné
|
||||||
|
- `chapterNumber`: Numéro de chapitre (optionnel, float)
|
||||||
|
- `volumeNumber`: Numéro de volume (optionnel, float)
|
||||||
|
|
||||||
|
## 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,105 @@
|
|||||||
|
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 {string|null} chapterId - ID du chapitre (optionnel)
|
||||||
|
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
|
||||||
|
* @returns {Promise<Object>} - Résultat de l'import
|
||||||
|
*/
|
||||||
|
async importFile(file, mangaId, chapterId = null, volumeNumber = null) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('mangaId', mangaId);
|
||||||
|
|
||||||
|
if (chapterId) {
|
||||||
|
formData.append('chapterId', chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volumeNumber) {
|
||||||
|
formData.append('volumeNumber', volumeNumber.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Importing file:', file.name, 'for manga:', mangaId);
|
||||||
|
const response = await fetch('/api/import/upload-file', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Import failed:', response.status, errorText);
|
||||||
|
throw new Error(`Failed to import file: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Import result:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<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-2">
|
||||||
|
Sélectionner un manga
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:value="file.selectedManga?.id || ''"
|
||||||
|
@change="handleMangaSelection"
|
||||||
|
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">-- Choisir un manga --</option>
|
||||||
|
<option
|
||||||
|
v-for="manga in file.getMatches()"
|
||||||
|
:key="manga.id"
|
||||||
|
:value="manga.id"
|
||||||
|
>
|
||||||
|
{{ manga.title }} (Score: {{ manga.matchScore }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Manga Preview -->
|
||||||
|
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-gray-50 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>
|
||||||
|
</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 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'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleMangaSelection = (event) => {
|
||||||
|
const mangaId = event.target.value;
|
||||||
|
if (mangaId) {
|
||||||
|
const selectedManga = props.file.getMatches().find(m => m.id === mangaId);
|
||||||
|
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,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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||||
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.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 AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||||
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||||
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||||
@@ -56,10 +57,16 @@ const routes = [
|
|||||||
component: ChapterPage,
|
component: ChapterPage,
|
||||||
props: { title: 'Lecteur' }
|
props: { title: 'Lecteur' }
|
||||||
},
|
},
|
||||||
|
// Import routes
|
||||||
|
{
|
||||||
|
path: '/import',
|
||||||
|
name: 'import',
|
||||||
|
component: NewImportPage
|
||||||
|
},
|
||||||
// Pages placeholder avec chargement différé
|
// Pages placeholder avec chargement différé
|
||||||
{
|
{
|
||||||
path: '/manga/import',
|
path: '/manga/import',
|
||||||
name: 'import',
|
name: 'manga-import',
|
||||||
component: PlaceholderComponent,
|
component: PlaceholderComponent,
|
||||||
props: { title: 'Import de bibliothèque' }
|
props: { title: 'Import de bibliothèque' }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
|||||||
{
|
{
|
||||||
icon: ArrowDownTrayIcon,
|
icon: ArrowDownTrayIcon,
|
||||||
text: 'Import bibliothèque',
|
text: 'Import bibliothèque',
|
||||||
to: '/manga/import'
|
to: '/import'
|
||||||
},
|
},
|
||||||
{ icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' }
|
{ 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>
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ framework:
|
|||||||
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
'App\Domain\Scraping\Domain\Event\ChapterScrapingFailed': events
|
||||||
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
'App\Domain\Manga\Domain\Event\ChapterReadyForScraping': events
|
||||||
'App\Domain\Manga\Domain\Event\MangaCreated': 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)
|
# Legacy messages (à garder si nécessaire)
|
||||||
'App\Message\DownloadChapter': commands
|
'App\Message\DownloadChapter': commands
|
||||||
|
|||||||
@@ -126,7 +126,13 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: messenger.message_handler, bus: command.bus }
|
- { 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:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
@@ -158,6 +164,31 @@ services:
|
|||||||
App\Domain\Shared\Domain\Contract\EventDispatcherInterface:
|
App\Domain\Shared\Domain\Contract\EventDispatcherInterface:
|
||||||
alias: App\Domain\Shared\Infrastructure\Messenger\SymfonyMessengerEventDispatcher
|
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:
|
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
|
||||||
tags:
|
tags:
|
||||||
- { name: messenger.message_handler, bus: command.bus }
|
- { 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",
|
"puppeteer": "^22.10.0",
|
||||||
"react-router-dom": "^7.1.5",
|
"react-router-dom": "^7.1.5",
|
||||||
"sortablejs": "^1.15.2",
|
"sortablejs": "^1.15.2",
|
||||||
"tailwindcss": "^3.2.7"
|
"tailwindcss": "^3.2.7",
|
||||||
|
"vuedraggable": "^2.24.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App\Domain\Manga\Application\CommandHandler;
|
|||||||
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
use App\Domain\Manga\Application\Command\FetchMangaChapters;
|
||||||
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
|
||||||
use App\Domain\Manga\Domain\Contract\Service\ChapterSynchronizationServiceInterface;
|
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
|
readonly class FetchMangaChaptersHandler
|
||||||
{
|
{
|
||||||
@@ -18,7 +20,11 @@ readonly class FetchMangaChaptersHandler
|
|||||||
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
$manga = $this->mangaRepository->findById($command->mangaId->getValue());
|
||||||
|
|
||||||
if ($manga === null) {
|
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)
|
// Synchronisation initiale (pas d'événements)
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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(),
|
id: $manga->getId()->getValue(),
|
||||||
title: $manga->getTitle()->getValue(),
|
title: $manga->getTitle()->getValue(),
|
||||||
slug: $manga->getSlug()->getValue(),
|
slug: $manga->getSlug()->getValue(),
|
||||||
|
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||||
description: $manga->getDescription(),
|
description: $manga->getDescription(),
|
||||||
author: $manga->getAuthor(),
|
author: $manga->getAuthor(),
|
||||||
publicationYear: $manga->getPublicationYear(),
|
publicationYear: $manga->getPublicationYear(),
|
||||||
@@ -34,7 +35,8 @@ readonly class GetMangaBySlugHandler
|
|||||||
externalId: $manga->getExternalId()?->getValue(),
|
externalId: $manga->getExternalId()?->getValue(),
|
||||||
imageUrl: $manga->getImageUrl(),
|
imageUrl: $manga->getImageUrl(),
|
||||||
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
|
||||||
rating: $manga->getRating()
|
rating: $manga->getRating(),
|
||||||
|
monitored: $manga->isMonitored()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ readonly class SearchLocalMangaHandler
|
|||||||
id: $manga->getId()->getValue(),
|
id: $manga->getId()->getValue(),
|
||||||
title: $manga->getTitle()->getValue(),
|
title: $manga->getTitle()->getValue(),
|
||||||
slug: $manga->getSlug()->getValue(),
|
slug: $manga->getSlug()->getValue(),
|
||||||
|
alternativeSlugs: $manga->getAlternativeSlugs(),
|
||||||
description: $manga->getDescription(),
|
description: $manga->getDescription(),
|
||||||
author: $manga->getAuthor(),
|
author: $manga->getAuthor(),
|
||||||
publicationYear: $manga->getPublicationYear(),
|
publicationYear: $manga->getPublicationYear(),
|
||||||
@@ -33,7 +34,8 @@ readonly class SearchLocalMangaHandler
|
|||||||
externalId: $manga->getExternalId()?->getValue() ?? '',
|
externalId: $manga->getExternalId()?->getValue() ?? '',
|
||||||
imageUrl: $manga->getImageUrls()->getFull(),
|
imageUrl: $manga->getImageUrls()->getFull(),
|
||||||
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
thumbnailUrl: $manga->getImageUrls()->getThumbnail(),
|
||||||
rating: $manga->getRating()
|
rating: $manga->getRating(),
|
||||||
|
monitored: $manga->isMonitored()
|
||||||
),
|
),
|
||||||
$mangas
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
return $this->monitoringStatus->isEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isMonitored(): bool
|
||||||
|
{
|
||||||
|
return $this->monitoringStatus->isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
public function enableMonitoring(): void
|
public function enableMonitoring(): void
|
||||||
{
|
{
|
||||||
$this->monitoringStatus = MonitoringStatus::enabled();
|
$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,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 = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
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\Dto\MangaSearchCollection;
|
||||||
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider;
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaStateProvider;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
shortName: 'Manga',
|
shortName: 'MangaSearch',
|
||||||
operations: [
|
operations: [
|
||||||
new Get(
|
new GetCollection(
|
||||||
uriTemplate: '/mangas/search',
|
uriTemplate: '/manga-search',
|
||||||
provider: SearchLocalMangaStateProvider::class,
|
provider: SearchLocalMangaStateProvider::class,
|
||||||
output: MangaSearchCollection::class,
|
output: MangaSearchCollection::class,
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -82,4 +82,4 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchLocalMangaS
|
|||||||
)]
|
)]
|
||||||
class SearchLocalMangaResource
|
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,
|
id: $response->id,
|
||||||
title: $response->title,
|
title: $response->title,
|
||||||
slug: $response->slug,
|
slug: $response->slug,
|
||||||
|
alternativeSlugs: $response->alternativeSlugs,
|
||||||
description: $response->description,
|
description: $response->description,
|
||||||
author: $response->author,
|
author: $response->author,
|
||||||
publicationYear: $response->publicationYear,
|
publicationYear: $response->publicationYear,
|
||||||
@@ -31,7 +32,8 @@ readonly class GetMangaBySlugStateProvider implements ProviderInterface
|
|||||||
externalId: $response->externalId,
|
externalId: $response->externalId,
|
||||||
imageUrl: $response->imageUrl,
|
imageUrl: $response->imageUrl,
|
||||||
thumbnailUrl: $response->thumbnailUrl,
|
thumbnailUrl: $response->thumbnailUrl,
|
||||||
rating: $response->rating
|
rating: $response->rating,
|
||||||
|
monitored: $response->monitored
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
$queryBuilder = $this->entityManager->createQueryBuilder()
|
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
||||||
->select('m')
|
$sql = "SELECT m.* FROM manga m
|
||||||
->from(EntityManga::class, 'm')
|
WHERE m.title LIKE :query
|
||||||
->where('m.title LIKE :query')
|
OR m.slug LIKE :query
|
||||||
->orWhere('m.slug LIKE :query')
|
OR CAST(m.alternative_slugs AS TEXT) LIKE :query
|
||||||
// ->orWhere('m.author LIKE :query')
|
ORDER BY m.title ASC
|
||||||
// ->orWhere('m.description LIKE :query')
|
LIMIT :limit OFFSET :offset";
|
||||||
->setParameter('query', '%' . $query . '%')
|
|
||||||
->orderBy('m.title', 'ASC')
|
$rsm = new \Doctrine\ORM\Query\ResultSetMapping();
|
||||||
->setFirstResult($offset)
|
$rsm->addEntityResult(EntityManga::class, 'm');
|
||||||
->setMaxResults($limit);
|
$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(
|
return array_map(
|
||||||
fn (EntityManga $entity) => $this->toDomain($entity),
|
fn (EntityManga $entity) => $this->toDomain($entity),
|
||||||
$queryBuilder->getQuery()->getResult()
|
$nativeQuery->getResult()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countSearch(string $query): int
|
public function countSearch(string $query): int
|
||||||
{
|
{
|
||||||
return $this->entityManager->createQueryBuilder()
|
// Utiliser une requête native pour supporter la recherche dans le champ JSON AlternativeSlugs
|
||||||
->select('COUNT(m.id)')
|
$sql = "SELECT COUNT(m.id) FROM manga m
|
||||||
->from(EntityManga::class, 'm')
|
WHERE m.title LIKE :query
|
||||||
->where('m.title LIKE :query')
|
OR m.slug LIKE :query
|
||||||
->orWhere('m.slug LIKE :query')
|
OR m.author LIKE :query
|
||||||
->orWhere('m.author LIKE :query')
|
OR m.description LIKE :query
|
||||||
->orWhere('m.description LIKE :query')
|
OR CAST(m.alternative_slugs AS TEXT) LIKE :query";
|
||||||
->setParameter('query', '%' . $query . '%')
|
|
||||||
->getQuery()
|
$conn = $this->entityManager->getConnection();
|
||||||
->getSingleScalarResult();
|
$stmt = $conn->prepare($sql);
|
||||||
|
$result = $stmt->executeQuery(['query' => '%' . $query . '%']);
|
||||||
|
|
||||||
|
return (int) $result->fetchOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ readonly class MangadexProvider implements MangaProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mangas = $this->createMangasFromResults($results['data']);
|
$mangas = $this->createMangasFromResults($results['data']);
|
||||||
// $this->enrichWithRatings($mangas);
|
$this->enrichWithRatings($mangas);
|
||||||
|
|
||||||
usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));
|
usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0));
|
||||||
|
|
||||||
|
|||||||
106
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal file
106
src/Domain/Manga/Infrastructure/Service/FilenameAnalyzer.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?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 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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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\Contract\Service\CbzGeneratorInterface;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
|
||||||
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
|
||||||
|
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
|
||||||
|
|
||||||
readonly class CbzGenerator implements CbzGeneratorInterface
|
readonly class CbzGenerator implements CbzGeneratorInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private string $projectDir
|
private MangaPathManagerInterface $mangaPathManager,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function generate(CbzGenerationRequest $request): CbzPath
|
public function generate(CbzGenerationRequest $request): CbzPath
|
||||||
{
|
{
|
||||||
$cbzPath = $this->generateCbzPath($request);
|
$cbzPath = $this->mangaPathManager->buildChapterCbzPath(
|
||||||
$this->createCbzArchive($request->getFiles(), $cbzPath);
|
$request->getMangaTitle(),
|
||||||
|
$request->getPublicationYear(),
|
||||||
|
$request->getVolumeNumber(),
|
||||||
|
$request->getChapterNumber(),
|
||||||
|
);
|
||||||
|
$this->mangaPathManager->createCbzArchive($request->getFiles(), $cbzPath);
|
||||||
return new CbzPath($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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveChapter(Chapter $chapter): void
|
public function saveChapter(Chapter $chapter): ChapterId
|
||||||
{
|
{
|
||||||
$this->savedChapters[] = $chapter;
|
$this->savedChapters[] = $chapter;
|
||||||
if (!isset($this->chapters[$chapter->getMangaId()])) {
|
if (!isset($this->chapters[$chapter->getMangaId()])) {
|
||||||
$this->chapters[$chapter->getMangaId()] = [];
|
$this->chapters[$chapter->getMangaId()] = [];
|
||||||
}
|
}
|
||||||
$this->chapters[$chapter->getMangaId()][] = $chapter;
|
$this->chapters[$chapter->getMangaId()][] = $chapter;
|
||||||
|
return new ChapterId($chapter->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<Chapter> */
|
/** @return array<Chapter> */
|
||||||
@@ -160,6 +161,11 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
|||||||
$manga->getDescription()
|
$manga->getDescription()
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Ajouter les slugs alternatifs aux champs de recherche
|
||||||
|
foreach ($manga->getAlternativeSlugs() as $altSlug) {
|
||||||
|
$searchableFields[] = $altSlug;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($searchableFields as $field) {
|
foreach ($searchableFields as $field) {
|
||||||
if (str_contains(strtolower($field), strtolower($query))) {
|
if (str_contains(strtolower($field), strtolower($query))) {
|
||||||
return true;
|
return 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\InMemoryMangaProvider;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
|
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
|
||||||
use App\Tests\Shared\Adapter\InMemoryMessageBus;
|
use App\Tests\Shared\Adapter\InMemoryEventDispatcher;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class CreateMangaFromMangadexHandlerTest extends TestCase
|
class CreateMangaFromMangadexHandlerTest extends TestCase
|
||||||
@@ -22,7 +22,7 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
|||||||
private InMemoryMangaRepository $repository;
|
private InMemoryMangaRepository $repository;
|
||||||
private InMemoryImageProcessor $imageProcessor;
|
private InMemoryImageProcessor $imageProcessor;
|
||||||
private CreateMangaFromMangadexHandler $handler;
|
private CreateMangaFromMangadexHandler $handler;
|
||||||
private InMemoryMessageBus $messageBus;
|
private InMemoryEventDispatcher $eventDispatcher;
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$manga = new Manga(
|
$manga = new Manga(
|
||||||
@@ -41,12 +41,12 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
|||||||
$this->provider = new InMemoryMangaProvider([$manga]);
|
$this->provider = new InMemoryMangaProvider([$manga]);
|
||||||
$this->repository = new InMemoryMangaRepository();
|
$this->repository = new InMemoryMangaRepository();
|
||||||
$this->imageProcessor = new InMemoryImageProcessor();
|
$this->imageProcessor = new InMemoryImageProcessor();
|
||||||
$this->messageBus = new InMemoryMessageBus();
|
$this->eventDispatcher = new InMemoryEventDispatcher();
|
||||||
$this->handler = new CreateMangaFromMangadexHandler(
|
$this->handler = new CreateMangaFromMangadexHandler(
|
||||||
$this->provider,
|
$this->provider,
|
||||||
$this->repository,
|
$this->repository,
|
||||||
$this->imageProcessor,
|
$this->imageProcessor,
|
||||||
$this->messageBus
|
$this->eventDispatcher
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,4 +76,4 @@ class CreateMangaFromMangadexHandlerTest extends TestCase
|
|||||||
// Act
|
// Act
|
||||||
$this->handler->handle($command);
|
$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\Command\FetchMangaChapters;
|
||||||
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
|
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\Manga;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangadexClient;
|
use App\Tests\Domain\Manga\Adapter\InMemoryChapterSynchronizationService;
|
||||||
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
class FetchMangaChaptersHandlerTest extends TestCase
|
class FetchMangaChaptersHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryMangadexClient $mangadexClient;
|
private InMemoryChapterSynchronizationService $chapterSynchronizationService;
|
||||||
private InMemoryMangaRepository $mangaRepository;
|
private InMemoryMangaRepository $mangaRepository;
|
||||||
private FetchMangaChaptersHandler $handler;
|
private FetchMangaChaptersHandler $handler;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->mangadexClient = new InMemoryMangadexClient();
|
$this->chapterSynchronizationService = new InMemoryChapterSynchronizationService();
|
||||||
$this->mangaRepository = new InMemoryMangaRepository();
|
$this->mangaRepository = new InMemoryMangaRepository();
|
||||||
$this->handler = new FetchMangaChaptersHandler(
|
$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->mangaRepository->save($manga);
|
||||||
|
|
||||||
$this->mangadexClient->addFeed($externalId, [
|
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||||
[
|
|
||||||
'id' => 'chapter-1',
|
|
||||||
'attributes' => [
|
|
||||||
'chapter' => '1',
|
|
||||||
'title' => 'Chapter 1',
|
|
||||||
'volume' => '1',
|
|
||||||
'translatedLanguage' => 'fr'
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$command = new FetchMangaChapters($mangaId);
|
|
||||||
$this->handler->handle($command);
|
$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
|
public function testHandleWithNonExistingManga(): void
|
||||||
@@ -72,7 +63,7 @@ class FetchMangaChaptersHandlerTest extends TestCase
|
|||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(\RuntimeException::class);
|
||||||
$this->expectExceptionMessage('Manga not found');
|
$this->expectExceptionMessage('Manga not found');
|
||||||
|
|
||||||
$command = new FetchMangaChapters($mangaId);
|
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||||
$this->handler->handle($command);
|
$this->handler->handle($command);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +84,10 @@ class FetchMangaChaptersHandlerTest extends TestCase
|
|||||||
|
|
||||||
$this->mangaRepository->save($manga);
|
$this->mangaRepository->save($manga);
|
||||||
|
|
||||||
$this->expectException(\RuntimeException::class);
|
$this->expectException(MangadexApiException::class);
|
||||||
$this->expectExceptionMessage('Manga has no external ID');
|
$this->expectExceptionMessage('Manga has no external_id');
|
||||||
|
|
||||||
$command = new FetchMangaChapters($mangaId);
|
$command = new FetchMangaChapters(new MangaId($mangaId));
|
||||||
$this->handler->handle($command);
|
$this->handler->handle($command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,236 @@
|
|||||||
|
<?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']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
|
|||||||
'A test manga description',
|
'A test manga description',
|
||||||
'Test Author',
|
'Test Author',
|
||||||
'2024',
|
'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
|
// 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',
|
'A test manga with preferred sources',
|
||||||
'Test Author',
|
'Test Author',
|
||||||
'2024',
|
'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->getDescription(),
|
||||||
$manga->getAuthor(),
|
$manga->getAuthor(),
|
||||||
$manga->getPublicationYear(),
|
$manga->getPublicationYear(),
|
||||||
$sourceIds // Mise à jour des sources préférées
|
$manga->isMonitored(), // monitored
|
||||||
|
$sourceIds, // preferredSources
|
||||||
|
$manga->getAlternativeSlugs() // alternativeSlugs
|
||||||
);
|
);
|
||||||
$this->mangas[$mangaId] = $updatedManga;
|
$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\InMemoryEventBus;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
|
use App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader;
|
||||||
use App\Tests\Domain\Scraping\Adapter\InMemoryMangaRepository;
|
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\Scraping\Adapter\InMemorySourceRepository;
|
||||||
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -23,7 +23,7 @@ use PHPUnit\Framework\MockObject\MockObject;
|
|||||||
|
|
||||||
class ScrapeChapterHandlerTest extends TestCase
|
class ScrapeChapterHandlerTest extends TestCase
|
||||||
{
|
{
|
||||||
private InMemoryScraperAdapter $scraper;
|
private InMemoryScraperFactory $scraperFactory;
|
||||||
private InMemoryImageDownloader $imageDownloader;
|
private InMemoryImageDownloader $imageDownloader;
|
||||||
private InMemoryCbzGenerator $cbzGenerator;
|
private InMemoryCbzGenerator $cbzGenerator;
|
||||||
private InMemoryJobRepository $jobRepository;
|
private InMemoryJobRepository $jobRepository;
|
||||||
@@ -36,7 +36,7 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->scraper = new InMemoryScraperAdapter();
|
$this->scraperFactory = new InMemoryScraperFactory();
|
||||||
$this->imageDownloader = new InMemoryImageDownloader();
|
$this->imageDownloader = new InMemoryImageDownloader();
|
||||||
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
|
$this->cbzGenerator = new InMemoryCbzGenerator('/test/project/dir');
|
||||||
$this->jobRepository = new InMemoryJobRepository();
|
$this->jobRepository = new InMemoryJobRepository();
|
||||||
@@ -59,7 +59,7 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
));
|
));
|
||||||
|
|
||||||
$this->handler = new ScrapeChapterHandler(
|
$this->handler = new ScrapeChapterHandler(
|
||||||
$this->scraper,
|
$this->scraperFactory,
|
||||||
$this->imageDownloader,
|
$this->imageDownloader,
|
||||||
$this->cbzGenerator,
|
$this->cbzGenerator,
|
||||||
$this->jobRepository,
|
$this->jobRepository,
|
||||||
@@ -92,31 +92,6 @@ class ScrapeChapterHandlerTest extends TestCase
|
|||||||
$this->assertNotNull($chapter->cbzPath);
|
$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
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
$this->jobRepository->clear();
|
$this->jobRepository->clear();
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'manga' => $manga,
|
'manga' => $manga,
|
||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mangaId = $manga->getId();
|
$mangaId = $manga->getId();
|
||||||
@@ -108,7 +108,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 1.0,
|
'number' => 1.0,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ChapterFactory::createOne([
|
ChapterFactory::createOne([
|
||||||
@@ -116,7 +116,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 2.0,
|
'number' => 2.0,
|
||||||
'visible' => false, // Soft deleted
|
'visible' => false, // Soft deleted
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ChapterFactory::createOne([
|
ChapterFactory::createOne([
|
||||||
@@ -132,7 +132,7 @@ class DownloadVolumeTest extends AbstractApiTestCase
|
|||||||
'volume' => 1,
|
'volume' => 1,
|
||||||
'number' => 4.0,
|
'number' => 4.0,
|
||||||
'visible' => true,
|
'visible' => true,
|
||||||
'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz'
|
'cbzPath' => __DIR__ . '/../../Shared/Files/test-chapter.cbz'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$mangaId = $manga->getId();
|
$mangaId = $manga->getId();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class FetchMangaChaptersTest extends AbstractApiTestCase
|
|||||||
$messages = $this->messageBus->getDispatchedMessages();
|
$messages = $this->messageBus->getDispatchedMessages();
|
||||||
$this->assertCount(1, $messages);
|
$this->assertCount(1, $messages);
|
||||||
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
|
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
|
||||||
$this->assertEquals($mangaId, $messages[0]->mangaId);
|
$this->assertEquals(new MangaId($mangaId), $messages[0]->mangaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFetchChaptersWithInvalidMangaId(): void
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
// When
|
// When
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$response = $client->request('GET', '/api/mangas/search', [
|
$response = $client->request('GET', '/api/manga-search', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'q' => ''
|
'q' => ''
|
||||||
]
|
]
|
||||||
@@ -32,7 +32,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
|||||||
{
|
{
|
||||||
// When
|
// When
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$response = $client->request('GET', '/api/mangas/search', [
|
$response = $client->request('GET', '/api/manga-search', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'q' => 'on'
|
'q' => 'on'
|
||||||
]
|
]
|
||||||
@@ -55,7 +55,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
// When
|
// When
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$response = $client->request('GET', '/api/mangas/search', [
|
$response = $client->request('GET', '/api/manga-search', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'q' => 'one'
|
'q' => 'one'
|
||||||
]
|
]
|
||||||
@@ -81,7 +81,7 @@ class SearchMangaTest extends AbstractApiTestCase
|
|||||||
|
|
||||||
// When
|
// When
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
$response = $client->request('GET', '/api/mangas/search', [
|
$response = $client->request('GET', '/api/manga-search', [
|
||||||
'query' => [
|
'query' => [
|
||||||
'q' => 'dragon'
|
'q' => 'dragon'
|
||||||
]
|
]
|
||||||
@@ -141,4 +141,4 @@ class SearchMangaTest extends AbstractApiTestCase
|
|||||||
$this->entityManager->persist($manga);
|
$this->entityManager->persist($manga);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
tests/Fixtures/chapter.cbr
Normal file
BIN
tests/Fixtures/chapter.cbr
Normal file
Binary file not shown.
BIN
tests/Fixtures/large-file.cbz
Normal file
BIN
tests/Fixtures/large-file.cbz
Normal file
Binary file not shown.
1
tests/Fixtures/test.txt
Normal file
1
tests/Fixtures/test.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test content
|
||||||
43
tests/Shared/Adapter/InMemoryEventDispatcher.php
Normal file
43
tests/Shared/Adapter/InMemoryEventDispatcher.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Shared\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Shared\Domain\Contract\EventDispatcherInterface;
|
||||||
|
|
||||||
|
class InMemoryEventDispatcher implements EventDispatcherInterface
|
||||||
|
{
|
||||||
|
/** @var array<object> */
|
||||||
|
private array $dispatchedEvents = [];
|
||||||
|
|
||||||
|
public function dispatch(object $event): void
|
||||||
|
{
|
||||||
|
$this->dispatchedEvents[] = $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<object>
|
||||||
|
*/
|
||||||
|
public function getDispatchedEvents(): array
|
||||||
|
{
|
||||||
|
return $this->dispatchedEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->dispatchedEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of object
|
||||||
|
* @param class-string<T> $eventClass
|
||||||
|
* @return array<T>
|
||||||
|
*/
|
||||||
|
public function getDispatchedEventsOfType(string $eventClass): array
|
||||||
|
{
|
||||||
|
return array_filter(
|
||||||
|
$this->dispatchedEvents,
|
||||||
|
fn(object $event) => $event instanceof $eventClass
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user