Files
PatoisSMEH/backend/server.js

446 lines
12 KiB
JavaScript

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