V1 Fonctionnelle (pas de secu de l'api, ni front quali)
This commit is contained in:
1394
backend/package-lock.json
generated
Normal file
1394
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
backend/package.json
Normal file
23
backend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "patois-api",
|
||||
"version": "1.0.0",
|
||||
"description": "API backend pour l'application Patois Franco-Provençal",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"keywords": ["patois", "franco-provençal", "api", "express"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
430
backend/server.js
Normal file
430
backend/server.js
Normal file
@@ -0,0 +1,430 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3001
|
||||
|
||||
// Middleware
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
|
||||
// Chemin vers le dossier texts (dossier parent)
|
||||
const TEXTS_DIR = process.env.TEXTS_PATH || path.join(__dirname, '..', '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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
Reference in New Issue
Block a user