feat: analyse import + all tests fixed

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-10-15 16:14:15 +02:00
parent fbe9619224
commit 3170a7c60e
74 changed files with 4318 additions and 183 deletions

View 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

View 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();
}
}
});

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>