mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-23 15:18:16 +02:00
Feature: Support multi-images, Google Calendar et correction RTL
Images multiples : - Ajout table note_images dans la base de données - Routes POST/GET/DELETE /api/notes/:id/images - Dossier uploads/images séparé pour les images - Support de plusieurs images par note Google Calendar : - Ajout table calendar_events - Routes /api/calendar pour sync et récupération des événements - Box Rendez-vous dans l'interface avec affichage des événements à venir - Configuration Google Calendar dans l'admin (API key et Calendar ID) - Synchronisation manuelle avec Google Calendar API - Installation package googleapis Corrections : - Fix RichTextEditor: ajout dir="ltr" pour empêcher l'écriture RTL - Fix Image type conflict (lucide-react vs Note interface) - Ajout original_name à l'interface Image pour compatibilité Interface : - Box Rendez-vous empilée avec Todos et RSS dans la colonne droite - Nouvel onglet Google Calendar dans l'administration - Bouton "Synchroniser" pour rafraîchir les événements
This commit is contained in:
10 files changed
+946
-5
No files matched your search
@@ -0,0 +1,166 @@
|
||||
// Routes pour Google Calendar
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { google } = require('googleapis');
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
// Toutes les routes nécessitent authentification
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/calendar/events
|
||||
* Récupérer les événements du calendrier
|
||||
*/
|
||||
router.get('/events', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Récupérer les événements à venir depuis la base de données
|
||||
const events = await getAll(`
|
||||
SELECT
|
||||
id, google_event_id, title, description,
|
||||
start_time, end_time, location, html_link
|
||||
FROM calendar_events
|
||||
WHERE user_id = ? AND start_time >= ?
|
||||
ORDER BY start_time ASC
|
||||
LIMIT ?
|
||||
`, [req.user.id, now, limit]);
|
||||
|
||||
res.json(events || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des événements:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/calendar/sync
|
||||
* Synchroniser avec Google Calendar
|
||||
*/
|
||||
router.post('/sync', async (req, res) => {
|
||||
try {
|
||||
// Récupérer les paramètres Google Calendar depuis settings
|
||||
const apiKey = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_api_key'");
|
||||
const calendarId = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_id'");
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
return res.status(400).json({ error: 'Clé API Google Calendar non configurée' });
|
||||
}
|
||||
|
||||
if (!calendarId || !calendarId.value) {
|
||||
return res.status(400).json({ error: 'ID du calendrier non configuré' });
|
||||
}
|
||||
|
||||
// Créer un client Google Calendar
|
||||
const calendar = google.calendar({ version: 'v3', auth: apiKey.value });
|
||||
|
||||
// Récupérer les événements des 30 prochains jours
|
||||
const now = new Date();
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
|
||||
const response = await calendar.events.list({
|
||||
calendarId: calendarId.value,
|
||||
timeMin: now.toISOString(),
|
||||
timeMax: futureDate.toISOString(),
|
||||
maxResults: 50,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
const events = response.data.items || [];
|
||||
let syncedCount = 0;
|
||||
|
||||
// Sauvegarder chaque événement dans la base de données
|
||||
for (const event of events) {
|
||||
const startTime = event.start.dateTime || event.start.date;
|
||||
const endTime = event.end.dateTime || event.end.date;
|
||||
|
||||
// Vérifier si l'événement existe déjà
|
||||
const existing = await getOne(
|
||||
'SELECT id FROM calendar_events WHERE google_event_id = ? AND user_id = ?',
|
||||
[event.id, req.user.id]
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Mettre à jour l'événement existant
|
||||
await runQuery(`
|
||||
UPDATE calendar_events
|
||||
SET title = ?, description = ?, start_time = ?, end_time = ?,
|
||||
location = ?, html_link = ?, synced_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, [
|
||||
event.summary || 'Sans titre',
|
||||
event.description || '',
|
||||
startTime,
|
||||
endTime,
|
||||
event.location || '',
|
||||
event.htmlLink || '',
|
||||
existing.id
|
||||
]);
|
||||
} else {
|
||||
// Créer un nouvel événement
|
||||
await runQuery(`
|
||||
INSERT INTO calendar_events
|
||||
(google_event_id, user_id, title, description, start_time, end_time, location, html_link)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
event.id,
|
||||
req.user.id,
|
||||
event.summary || 'Sans titre',
|
||||
event.description || '',
|
||||
startTime,
|
||||
endTime,
|
||||
event.location || '',
|
||||
event.htmlLink || ''
|
||||
]);
|
||||
}
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
logger.info(`${syncedCount} événements synchronisés pour l'utilisateur ${req.user.username}`);
|
||||
|
||||
res.json({
|
||||
message: 'Synchronisation réussie',
|
||||
syncedCount,
|
||||
events: events.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la synchronisation avec Google Calendar:', error);
|
||||
res.status(500).json({
|
||||
error: 'Erreur lors de la synchronisation',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/calendar/events/:id
|
||||
* Supprimer un événement local
|
||||
*/
|
||||
router.delete('/events/:id', async (req, res) => {
|
||||
try {
|
||||
const event = await getOne(
|
||||
'SELECT id FROM calendar_events WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Événement non trouvé' });
|
||||
}
|
||||
|
||||
await runQuery('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
|
||||
|
||||
logger.info(`Événement ${req.params.id} supprimé`);
|
||||
|
||||
res.json({ message: 'Événement supprimé avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression de l\'événement:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+171
-1
@@ -58,7 +58,7 @@ router.get('/', async (req, res) => {
|
||||
ORDER BY n.updated_at DESC
|
||||
`, [req.user.id, archivedFilter]);
|
||||
|
||||
// Charger les todos et fichiers pour chaque note
|
||||
// Charger les todos, images et fichiers pour chaque note
|
||||
for (const note of notes) {
|
||||
const todos = await getAll(`
|
||||
SELECT id, text, completed, position
|
||||
@@ -68,6 +68,14 @@ router.get('/', async (req, res) => {
|
||||
`, [note.id]);
|
||||
note.todos = todos || [];
|
||||
|
||||
const images = await getAll(`
|
||||
SELECT id, filename, original_name, created_at
|
||||
FROM note_images
|
||||
WHERE note_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [note.id]);
|
||||
note.images = images || [];
|
||||
|
||||
const files = await getAll(`
|
||||
SELECT id, filename, original_name, file_size, mime_type, created_at
|
||||
FROM note_files
|
||||
@@ -111,6 +119,26 @@ router.get('/:id', async (req, res) => {
|
||||
|
||||
note.todos = todos;
|
||||
|
||||
// Récupérer les images de la note
|
||||
const images = await getAll(`
|
||||
SELECT id, filename, original_name, created_at
|
||||
FROM note_images
|
||||
WHERE note_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [req.params.id]);
|
||||
|
||||
note.images = images || [];
|
||||
|
||||
// Récupérer les fichiers de la note
|
||||
const files = await getAll(`
|
||||
SELECT id, filename, original_name, file_size, mime_type, created_at
|
||||
FROM note_files
|
||||
WHERE note_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [req.params.id]);
|
||||
|
||||
note.files = files || [];
|
||||
|
||||
res.json(note);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération de la note:', error);
|
||||
@@ -487,6 +515,148 @@ const searchNotes = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== IMAGE ATTACHMENTS ====================
|
||||
|
||||
// Configuration multer pour les images multiples
|
||||
const imageStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const imagesDir = path.join(__dirname, '../public/uploads/images');
|
||||
// Créer le dossier s'il n'existe pas
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
cb(null, imagesDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
const imageUpload = multer({
|
||||
storage: imageStorage,
|
||||
limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Type de fichier non autorisé. Utilisez JPEG, PNG, WebP ou GIF.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/notes/:id/images
|
||||
* Ajouter une image à une note
|
||||
*/
|
||||
router.post('/:id/images', imageUpload.single('image'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucune image fournie' });
|
||||
}
|
||||
|
||||
// Vérifier que la note appartient à l'utilisateur
|
||||
const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!note) {
|
||||
// Supprimer le fichier uploadé
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(404).json({ error: 'Note non trouvée' });
|
||||
}
|
||||
|
||||
// Enregistrer l'image dans la base de données
|
||||
const result = await runQuery(`
|
||||
INSERT INTO note_images (note_id, filename, original_name)
|
||||
VALUES (?, ?, ?)
|
||||
`, [req.params.id, req.file.filename, req.file.originalname]);
|
||||
|
||||
// Mettre à jour la date de modification de la note
|
||||
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]);
|
||||
|
||||
logger.info(`Image ajoutée à la note ${req.params.id}: ${req.file.filename}`);
|
||||
|
||||
res.json({
|
||||
message: 'Image ajoutée avec succès',
|
||||
image: {
|
||||
id: result.id,
|
||||
filename: req.file.filename,
|
||||
original_name: req.file.originalname
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de l\'ajout de l\'image:', error);
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/notes/:id/images
|
||||
* Liste les images d'une note
|
||||
*/
|
||||
router.get('/:id/images', async (req, res) => {
|
||||
try {
|
||||
// Vérifier que la note appartient à l'utilisateur
|
||||
const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note non trouvée' });
|
||||
}
|
||||
|
||||
const images = await getAll(`
|
||||
SELECT id, filename, original_name, created_at
|
||||
FROM note_images
|
||||
WHERE note_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [req.params.id]);
|
||||
|
||||
res.json(images);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des images:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/notes/:noteId/images/:imageId
|
||||
* Supprimer une image d'une note
|
||||
*/
|
||||
router.delete('/:noteId/images/:imageId', async (req, res) => {
|
||||
try {
|
||||
// Vérifier que l'image appartient à une note de l'utilisateur
|
||||
const image = await getOne(`
|
||||
SELECT ni.id, ni.filename, ni.note_id
|
||||
FROM note_images ni
|
||||
JOIN notes n ON ni.note_id = n.id
|
||||
WHERE ni.id = ? AND ni.note_id = ? AND n.user_id = ?
|
||||
`, [req.params.imageId, req.params.noteId, req.user.id]);
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({ error: 'Image non trouvée' });
|
||||
}
|
||||
|
||||
// Supprimer le fichier physique
|
||||
const imagePath = path.join(__dirname, '../public/uploads/images', image.filename);
|
||||
if (fs.existsSync(imagePath)) {
|
||||
fs.unlinkSync(imagePath);
|
||||
}
|
||||
|
||||
// Supprimer l'entrée de la base de données
|
||||
await runQuery('DELETE FROM note_images WHERE id = ?', [req.params.imageId]);
|
||||
|
||||
// Mettre à jour la date de modification de la note
|
||||
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.noteId]);
|
||||
|
||||
logger.info(`Image ${image.filename} supprimée de la note ${req.params.noteId}`);
|
||||
|
||||
res.json({ message: 'Image supprimée avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression de l\'image:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== FILE ATTACHMENTS ====================
|
||||
|
||||
// Configuration multer pour tous types de fichiers
|
||||
|
||||
Reference in new issue
Block a user