Files
noteflow/routes/rss.routes.js
Claude 7354818d3e UX: Titre 'À faire' + Source RSS + Tri todos + Notifications internes
1. Titre 'À faire' au lieu de 'Quick Tasks'
   - Changé public/index.html ligne 126
   - Meilleure localisation française

2. Affichage source RSS au lieu du modèle IA
   - Ajout colonne feed_title à rss_summaries (migration auto)
   - Backend stocke feed_title lors de la génération
   - GET /api/rss/summaries retourne feed_title
   - Frontend affiche 📰 Source + 📅 Date au lieu du modèle
   - Plus pertinent pour l'utilisateur

3. Tri automatique des todos : actives en premier
   - renderTodos() trie : actives avant complétées
   - Si même statut : tri par ID décroissant (récentes en premier)
   - Sort: a.completed ? 1 : -1

4. Système de notifications internes
   - Remplacé TOUS les alert() par showNotification()
   - Types : success, error, warning, info
   - Animations : slide-in/slide-out depuis la droite
   - Auto-fermeture après 4 secondes
   - Bouton de fermeture manuelle
   - Position : top-right, z-index 300
   - Icônes SVG selon le type
   - Couleurs : vert (success), rouge (error), jaune (warning), bleu (info)

Frontend - Notifications:
   - showNotification(message, type, duration)
   - closeNotification(notification)
   - Container fixe en haut à droite
   - Empilage vertical des notifications
   - Escape HTML automatique

CSS - Nouveaux styles:
   - .notifications-container (fixed, top-right)
   - .notification + variations par type
   - Animations slideInRight / slideOutRight
   - Border-left colorée selon le type
   - Responsive et accessible

Impact utilisateur:
   ✓ Plus de popups système alert() invasives
   ✓ Notifications élégantes et non-bloquantes
   ✓ Feedback visuel clair avec icônes et couleurs
   ✓ Source RSS affichée au lieu du modèle IA
   ✓ Todos actives toujours en haut
   ✓ Interface 100% en français

Files:
   - public/index.html: Titre + container notifications
   - public/css/skeleton.css: Styles notifications
   - public/js/complete-app.js: showNotification() + remplacement 40+ alert()
   - config/database.js: Migration feed_title
   - routes/rss.routes.js: Stockage et retour feed_title
2025-11-11 19:35:14 +00:00

368 lines
11 KiB
JavaScript

// Routes de gestion des flux RSS
const express = require('express');
const router = express.Router();
const Parser = require('rss-parser');
const axios = require('axios');
const { getAll, getOne, runQuery } = require('../config/database');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const logger = require('../config/logger');
const parser = new Parser();
// Routes admin (gestion des flux)
router.use(authenticateToken);
/**
* GET /api/rss/feeds
* Liste tous les flux RSS
*/
router.get('/feeds', requireAdmin, async (req, res) => {
try {
const feeds = await getAll('SELECT * FROM rss_feeds ORDER BY created_at DESC');
res.json(feeds || []);
} catch (error) {
logger.error('Erreur lors de la récupération des flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/feeds
* Ajouter un nouveau flux RSS
*/
router.post('/feeds', requireAdmin, async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL requise' });
}
// Vérifier si le flux existe déjà
const existing = await getOne('SELECT id FROM rss_feeds WHERE url = ?', [url]);
if (existing) {
return res.status(409).json({ error: 'Ce flux existe déjà' });
}
// Tester le flux
try {
const feed = await parser.parseURL(url);
// Ajouter le flux
const result = await runQuery(
'INSERT INTO rss_feeds (url, title, description) VALUES (?, ?, ?)',
[url, feed.title || url, feed.description || '']
);
logger.info(`Flux RSS ajouté: ${feed.title} (${url})`);
res.json({
id: result.id,
url,
title: feed.title || url,
description: feed.description || '',
enabled: 1
});
} catch (parseError) {
logger.error('Erreur lors du parsing du flux RSS:', parseError);
return res.status(400).json({ error: 'Impossible de parser ce flux RSS. Vérifiez l\'URL.' });
}
} catch (error) {
logger.error('Erreur lors de l\'ajout du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* PUT /api/rss/feeds/:id
* Mettre à jour un flux RSS (activer/désactiver)
*/
router.put('/feeds/:id', requireAdmin, async (req, res) => {
try {
const { enabled } = req.body;
await runQuery(
'UPDATE rss_feeds SET enabled = ? WHERE id = ?',
[enabled ? 1 : 0, req.params.id]
);
logger.info(`Flux RSS ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
res.json({ message: 'Flux mis à jour avec succès' });
} catch (error) {
logger.error('Erreur lors de la mise à jour du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* DELETE /api/rss/feeds/:id
* Supprimer un flux RSS
*/
router.delete('/feeds/:id', requireAdmin, async (req, res) => {
try {
await runQuery('DELETE FROM rss_feeds WHERE id = ?', [req.params.id]);
logger.info(`Flux RSS supprimé: ${req.params.id}`);
res.json({ message: 'Flux supprimé avec succès' });
} catch (error) {
logger.error('Erreur lors de la suppression du flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/fetch
* Récupérer manuellement les articles de tous les flux RSS activés
*/
router.post('/fetch', requireAdmin, async (req, res) => {
try {
const rssScheduler = require('../services/rss-scheduler');
await rssScheduler.manualFetch();
// Invalider le cache
articlesCache = null;
articlesCacheTime = 0;
res.json({ message: 'Mise à jour des flux RSS terminée avec succès' });
} catch (error) {
logger.error('Erreur lors du fetch manuel des flux RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Cache pour les articles RSS (30 secondes)
let articlesCache = null;
let articlesCacheTime = 0;
const ARTICLES_CACHE_DURATION = 30000; // 30 secondes
/**
* GET /api/rss/articles
* Récupérer les 5 derniers articles (avec cache)
*/
router.get('/articles', async (req, res) => {
try {
// Utiliser le cache si disponible et récent
const now = Date.now();
if (articlesCache && (now - articlesCacheTime) < ARTICLES_CACHE_DURATION) {
logger.debug('Articles RSS servis depuis le cache');
return res.json(articlesCache);
}
const articles = await getAll(`
SELECT
a.id, a.title, a.link, a.description, a.pub_date, a.content,
COALESCE(f.title, f.url) as feed_title, f.url as feed_url
FROM rss_articles a
LEFT JOIN rss_feeds f ON a.feed_id = f.id
WHERE a.pub_date IS NOT NULL
ORDER BY a.pub_date DESC
LIMIT 5
`);
// Mettre en cache
articlesCache = articles || [];
articlesCacheTime = now;
logger.info(`Articles RSS récupérés: ${articles?.length || 0}`);
res.json(articles || []);
} catch (error) {
logger.error('Erreur lors de la récupération des articles RSS:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* POST /api/rss/summarize
* Générer un résumé PAR ARTICLE (100 mots max) avec lien
*/
router.post('/summarize', async (req, res) => {
try {
// Récupérer les paramètres
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']);
if (!apiKey || !apiKey.value) {
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
}
// Récupérer les 5 derniers articles
const articles = await getAll(`
SELECT
a.id, a.title, a.description, a.link, a.pub_date, a.content,
COALESCE(f.title, f.url) as feed_title
FROM rss_articles a
LEFT JOIN rss_feeds f ON a.feed_id = f.id
WHERE a.pub_date IS NOT NULL
ORDER BY a.pub_date DESC
LIMIT 5
`);
if (articles.length === 0) {
return res.status(400).json({ error: 'Aucun article à résumer' });
}
// Générer un résumé pour CHAQUE article (en parallèle)
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
logger.info(`Génération de ${articles.length} résumés avec ${selectedModel}...`);
const summaryPromises = articles.map(async (article) => {
const prompt = `Résume cet article en maximum 100 mots. Sois concis et informatif.
Titre: ${article.title}
Source: ${article.feed_title}
Description: ${article.description || ''}
${article.content ? `Contenu: ${article.content.substring(0, 1000)}` : ''}
Résumé (100 mots max):`;
try {
const response = await axios.post(
'https://openrouter.ai/api/v1/chat/completions',
{
model: selectedModel,
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 200
},
{
headers: {
'Authorization': `Bearer ${apiKey.value}`,
'HTTP-Referer': 'https://noteflow.app',
'X-Title': 'NoteFlow RSS Summarizer',
'Content-Type': 'application/json'
},
timeout: 30000
}
);
const summary = response.data.choices[0].message.content;
// Sauvegarder le résumé individuel avec feed_title
await runQuery(
'INSERT INTO rss_summaries (summary, model, articles_count, feed_title, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1, article.feed_title]
);
return {
article_id: article.id,
title: article.title,
link: article.link,
feed_title: article.feed_title,
summary: summary,
pub_date: article.pub_date
};
} catch (err) {
logger.error(`Erreur résumé article "${article.title}":`, err.message);
return {
article_id: article.id,
title: article.title,
link: article.link,
feed_title: article.feed_title,
summary: article.description || 'Résumé non disponible',
pub_date: article.pub_date,
error: true
};
}
});
const summaries = await Promise.all(summaryPromises);
logger.info(`${summaries.length} résumés générés avec succès`);
res.json({
summaries,
model: selectedModel,
articles_count: articles.length
});
} catch (error) {
logger.error('Erreur lors de la génération des résumés:', error);
if (error.response) {
logger.error('Réponse OpenRouter:', error.response.data);
return res.status(error.response.status).json({
error: 'Erreur API OpenRouter',
details: error.response.data
});
}
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* GET /api/rss/summaries
* Récupérer les 5 derniers résumés
*/
router.get('/summaries', async (req, res) => {
try {
const summaries = await getAll(`
SELECT id, summary, model, articles_count, feed_title, created_at
FROM rss_summaries
ORDER BY created_at DESC
LIMIT 5
`);
res.json(summaries || []);
} catch (error) {
logger.error('Erreur lors de la récupération des résumés:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
/**
* GET /api/rss/models
* Récupérer la liste des modèles OpenRouter depuis l'API
*/
router.get('/models', requireAdmin, async (req, res) => {
try {
// Récupérer les modèles depuis l'API OpenRouter
const response = await axios.get('https://openrouter.ai/api/v1/models', {
headers: {
'HTTP-Referer': 'https://noteflow.app',
'X-Title': 'NoteFlow'
},
timeout: 10000
});
// Transformer les données en format simplifié
const models = response.data.data.map(model => ({
id: model.id,
name: model.name || model.id,
provider: model.id.split('/')[0] || 'Unknown',
context_length: model.context_length,
pricing: model.pricing
}));
// Trier par provider puis par nom
models.sort((a, b) => {
if (a.provider !== b.provider) {
return a.provider.localeCompare(b.provider);
}
return a.name.localeCompare(b.name);
});
res.json(models);
} catch (error) {
logger.error('Erreur lors de la récupération des modèles:', error);
// Fallback sur une liste de modèles populaires si l'API échoue
const fallbackModels = [
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'openai' },
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'anthropic' },
{ id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
{ id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'google' },
{ id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'meta-llama' },
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'mistralai' }
];
res.json(fallbackModels);
}
});
module.exports = router;