import express from 'express' import cors from 'cors' import fs from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' import dotenv from 'dotenv' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) /** Configuration dotenv * Permet de charger les variables d'environnement depuis un fichier .env * */ dotenv.config() const app = express() const PORT = process.env.PORT // Middleware app.use(cors()) app.use(express.json()) // Chemin vers le dossier texts (dossier parent) const TEXTS_DIR = process.env.TEXTS_PATH || "C:\\Users\\paulf\\Documents\\texts" /** * Service pour scanner et charger les textes depuis le dossier texts/ */ class TextService { constructor() { this.cache = new Map() this.lastScan = null this.CACHE_DURATION = 5 * 60 * 1000 // 5 minutes // NOUVEAU : Index de recherche optimisé this.searchIndex = new Map() this.indexBuilt = false } /** * Parse le fichier metadata.txt */ parseMetadata(content) { const metadata = {} const lines = content.split('\n').filter(line => line.trim()) for (const line of lines) { const [key, ...valueParts] = line.split('=') if (key && valueParts.length > 0) { metadata[key.trim()] = valueParts.join('=').trim() } } return metadata } /** * Vérifie si un fichier existe */ async fileExists(filePath) { try { await fs.access(filePath) return true } catch { return false } } /** * Charge un texte spécifique par son ID */ async loadText(textId) { try { const textDir = path.join(TEXTS_DIR, textId) // Vérifier que le dossier existe const dirExists = await this.fileExists(textDir) if (!dirExists) { throw new Error(`Texte "${textId}" non trouvé`) } // Charger les fichiers principaux const frPath = path.join(textDir, 'fr.txt') const ptPath = path.join(textDir, 'pt.txt') const metadataPath = path.join(textDir, 'metadata.txt') const [frExists, ptExists, metadataExists] = await Promise.all([ this.fileExists(frPath), this.fileExists(ptPath), this.fileExists(metadataPath) ]) if (!frExists || !ptExists || !metadataExists) { throw new Error(`Fichiers manquants pour le texte "${textId}"`) } // Lire les fichiers const [frenchText, patoisText, metadataContent] = await Promise.all([ fs.readFile(frPath, 'utf-8'), fs.readFile(ptPath, 'utf-8'), fs.readFile(metadataPath, 'utf-8') ]) // Vérifier si un fichier audio existe const audioPath = path.join(textDir, 'audio.mp3') const hasAudio = await this.fileExists(audioPath) const metadata = this.parseMetadata(metadataContent) return { id: textId, frenchText: frenchText.trim(), patoisText: patoisText.trim(), metadata, hasAudio } } catch (error) { console.error(`Erreur lors du chargement du texte ${textId}:`, error) throw error } } /** * Scanne le dossier texts/ pour découvrir tous les textes disponibles */ async scanTexts() { try { // Vérifier si le cache est encore valide if (this.lastScan && (Date.now() - this.lastScan) < this.CACHE_DURATION) { return Array.from(this.cache.values()) } const dirExists = await this.fileExists(TEXTS_DIR) if (!dirExists) { console.warn(`Dossier texts/ non trouvé: ${TEXTS_DIR}`) return [] } const entries = await fs.readdir(TEXTS_DIR, { withFileTypes: true }) const textDirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name) const texts = [] for (const textId of textDirs) { try { const textData = await this.loadText(textId) texts.push(textData) this.cache.set(textId, textData) } catch (error) { console.warn(`Impossible de charger le texte ${textId}:`, error.message) } } this.lastScan = Date.now() // NOUVEAU : Construction de l'index de recherche après le scan this.buildSearchIndex(texts) return texts } catch (error) { console.error('Erreur lors du scan des textes:', error) throw error } } /** * NOUVEAU : Construit un index de recherche optimisé */ buildSearchIndex(texts) { console.log('🔍 Construction de l\'index de recherche...') this.searchIndex.clear() for (const text of texts) { const searchableContent = [ text.metadata.titre_fr || '', text.metadata.titre_pt || '', text.metadata.auteur || '', text.metadata.categorie || '', text.frenchText || '', text.patoisText || '' ].join(' ').toLowerCase() // Tokenisation simple mais efficace const words = searchableContent .split(/\s+/) .filter(word => word.length > 2) // Ignore les mots de moins de 3 caractères .map(word => word.replace(/[^\w]/g, '')) // Nettoie la ponctuation .filter(word => word.length > 0) // Indexation par mots for (const word of words) { if (!this.searchIndex.has(word)) { this.searchIndex.set(word, new Set()) } this.searchIndex.get(word).add(text.id) } } this.indexBuilt = true console.log(`✅ Index construit : ${this.searchIndex.size} mots uniques indexés`) } /** * OPTIMISÉ : Recherche rapide utilisant l'index */ searchWithIndex(query, allTexts) { if (!query || !query.trim()) return allTexts const searchTerms = query.toLowerCase() .split(/\s+/) .filter(term => term.length > 2) .map(term => term.replace(/[^\w]/g, '')) if (searchTerms.length === 0) return allTexts // Recherche dans l'index pour chaque terme let matchingIds = null for (const term of searchTerms) { const idsForTerm = new Set() // Recherche exacte et préfixe for (const [indexedWord, ids] of this.searchIndex) { if (indexedWord.includes(term)) { for (const id of ids) { idsForTerm.add(id) } } } // Intersection des résultats (ET logique) if (matchingIds === null) { matchingIds = idsForTerm } else { matchingIds = new Set([...matchingIds].filter(id => idsForTerm.has(id))) } // Si aucun résultat, arrêter if (matchingIds.size === 0) break } // Retourner les textes correspondants return allTexts.filter(text => matchingIds && matchingIds.has(text.id)) } /** * Recherche dans les textes */ async searchTexts(query, filters = {}) { const allTexts = await this.scanTexts() let results = [...allTexts] // Recherche textuelle if (query && query.trim()) { // Utiliser l'index de recherche si construit if (this.indexBuilt) { results = this.searchWithIndex(query, results) } else { const searchTerm = query.toLowerCase().trim() results = results.filter(text => { return ( text.metadata.titre_fr?.toLowerCase().includes(searchTerm) || text.metadata.titre_pt?.toLowerCase().includes(searchTerm) || text.metadata.auteur?.toLowerCase().includes(searchTerm) || text.frenchText?.toLowerCase().includes(searchTerm) || text.patoisText?.toLowerCase().includes(searchTerm) ) }) } } // Filtres if (filters.category) { results = results.filter(text => text.metadata.categorie === filters.category) } if (filters.difficulty) { results = results.filter(text => text.metadata.difficulte === filters.difficulty) } if (filters.onlyWithAudio === 'true') { results = results.filter(text => text.hasAudio) } return results } /** * NOUVEAU : Obtient toutes les catégories disponibles */ async getCategories() { const allTexts = await this.scanTexts() const categories = new Set() for (const text of allTexts) { if (text.metadata.categorie) { categories.add(text.metadata.categorie) } } return Array.from(categories).sort() } /** * Obtient les statistiques */ async getStats() { const allTexts = await this.scanTexts() const authors = new Set() const categories = new Set() let withAudio = 0 for (const text of allTexts) { if (text.metadata.auteur) authors.add(text.metadata.auteur) if (text.metadata.categorie) categories.add(text.metadata.categorie) if (text.hasAudio) withAudio++ } return { totalTexts: allTexts.length, withAudio, authors: authors.size, categories: categories.size } } async getRandomText() { const allTexts = await this.scanTexts() if (allTexts.length === 0) { throw new Error('Aucun texte disponible') } const randomIndex = Math.floor(Math.random() * allTexts.length) return allTexts[randomIndex] } } // Instance du service const textService = new TextService() // ==================== ROUTES API ==================== /** * GET /api/texts - Liste tous les textes */ app.get('/api/texts', async (req, res) => { try { const { search, category, difficulty, onlyWithAudio } = req.query if (search || category || difficulty || onlyWithAudio) { // Recherche avec filtres const results = await textService.searchTexts(search, { category, difficulty, onlyWithAudio }) res.json(results) } else { // Liste complète const texts = await textService.scanTexts() res.json(texts) } } catch (error) { console.error('Erreur GET /api/texts:', error) res.status(500).json({ error: 'Erreur lors du chargement des textes' }) } }) /** * GET /api/texts/:id - Détails d'un texte spécifique */ app.get('/api/texts/:id', async (req, res) => { try { const { id } = req.params const text = await textService.loadText(id) res.json(text) } catch (error) { console.error(`Erreur GET /api/texts/${req.params.id}:`, error) res.status(404).json({ error: error.message }) } }) /** * GET /api/texts/random - Texte aléatoire */ app.get('/api/random', async (req, res) => { try { const randomText = await textService.getRandomText() res.json(randomText) } catch (error) { console.error('Erreur GET /api/random:', error) res.status(500).json({ error: error.message }) } }) /** * GET /api/stats - Statistiques de la collection */ app.get('/api/stats', async (req, res) => { try { const stats = await textService.getStats() res.json(stats) } catch (error) { console.error('Erreur GET /api/stats:', error) res.status(500).json({ error: 'Erreur lors du chargement des statistiques' }) } }) /** * GET /api/texts/:id/audio - Fichier audio */ app.get('/api/texts/:id/audio', async (req, res) => { try { const { id } = req.params const audioPath = path.join(TEXTS_DIR, id, 'audio.mp3') const exists = await textService.fileExists(audioPath) if (!exists) { return res.status(404).json({ error: 'Fichier audio non trouvé' }) } // Servir le fichier audio res.sendFile(audioPath) } catch (error) { console.error(`Erreur GET /api/texts/${req.params.id}/audio:`, error) res.status(500).json({ error: 'Erreur lors du chargement du fichier audio' }) } }) // Route de santé app.get('/api/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString() }) }) // Démarrage du serveur app.listen(PORT, () => { console.log(`🚀 Serveur API Patois démarré sur http://localhost:${PORT}`) console.log(`📁 Dossier texts: ${TEXTS_DIR}`) console.log(`🔍 Endpoints disponibles:`) console.log(` GET /api/texts - Liste des textes`) console.log(` GET /api/texts/:id - Détails d'un texte`) console.log(` GET /api/random - Texte aléatoire`) console.log(` GET /api/stats - Statistiques`) console.log(` GET /api/texts/:id/audio - Fichier audio`) }) export default app