Merge pull request #32 from R0m1k3/claude/fix-npm-ci-build-error-011CV3c2K4ETHdBRQUs7tkJf

Feature: Support multi-images, Google Calendar et correction RTL
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-12 10:14:35 +01:00
commit e69103d8f4
10 files changed
+946 -5

No files matched your search

+33
View File
@@ -74,6 +74,18 @@ function initDatabase() {
)
`);
// Table note_images (images attachées aux notes)
db.run(`
CREATE TABLE IF NOT EXISTS note_images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
)
`);
// Table note_files (fichiers attachés aux notes)
db.run(`
CREATE TABLE IF NOT EXISTS note_files (
@@ -137,14 +149,35 @@ function initDatabase() {
)
`);
// Table calendar_events (événements Google Calendar)
db.run(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_event_id TEXT UNIQUE NOT NULL,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
location TEXT,
color_id TEXT,
html_link TEXT,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Créer les index pour la performance
db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_note_images ON note_images(note_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_note_files ON note_files(note_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key)');
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_feed ON rss_articles(feed_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_date ON rss_articles(pub_date)');
db.run('CREATE INDEX IF NOT EXISTS idx_calendar_events_user ON calendar_events(user_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(start_time)');
logger.info('✓ Tables de base de données créées avec succès');
+352
View File
@@ -49,6 +49,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"googleapis": "^166.0.0",
"helmet": "^7.1.0",
"input-otp": "^1.4.1",
"jsonwebtoken": "^9.0.2",
@@ -4338,6 +4339,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -5121,6 +5131,15 @@
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
@@ -5822,6 +5841,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5895,6 +5920,29 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6067,6 +6115,18 @@
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -6167,6 +6227,143 @@
"node": ">=10"
}
},
"node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gaxios/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/gaxios/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/gaxios/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gaxios/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/gaxios/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gaxios/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/gaxios/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/gaxios/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -6275,6 +6472,83 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-auth-library/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/google-auth-library/node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.2.tgz",
"integrity": "sha512-YsFPGVgDFf4IzSwbwIR0iaFJQFmR5Jp7V1WuYSjuRgAm9yWqsMhKE9YPlL+wvFLnc/wMiFV4SQUD9Y/JMpxIxQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/googleapis": {
"version": "166.0.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-166.0.0.tgz",
"integrity": "sha512-Xz7yG+ihVvYMwSULKIMeTp6vDX95rrSbsaR3xUmgYjwQdAkchMQ5ndFeG5QZwcBOIyvJFvG7ZLIZE7mKW2X/hw==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.2.0",
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/googleapis-common": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.0.tgz",
"integrity": "sha512-66if47It7y+Sab3HMkwEXx1kCq9qUC9px8ZXoj1CMrmLmUw81GpbnsNlXnlyZyGbGPGcj+tDD9XsZ23m7GLaJQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"gaxios": "^7.0.0-rc.4",
"google-auth-library": "^10.1.0",
"qs": "^6.7.0",
"url-template": "^2.0.8"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -6301,6 +6575,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gtoken/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/gtoken/node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6733,6 +7041,15 @@
"node": ">=6"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -7426,6 +7743,26 @@
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9833,6 +10170,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-template": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
"license": "BSD"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -10056,6 +10399,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+1
View File
@@ -68,6 +68,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"googleapis": "^166.0.0",
"helmet": "^7.1.0",
"input-otp": "^1.4.1",
"jsonwebtoken": "^9.0.2",
+166
View File
@@ -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
View File
@@ -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
+1
View File
@@ -86,6 +86,7 @@ app.use('/api/notes', require('./routes/notes.routes'));
app.use('/api/todos', require('./routes/todos.routes'));
app.use('/api/settings', require('./routes/settings.routes'));
app.use('/api/rss', require('./routes/rss.routes'));
app.use('/api/calendar', require('./routes/calendar.routes'));
// Route de recherche
app.get('/api/search', require('./routes/notes.routes').searchNotes);
+4 -1
View File
@@ -158,12 +158,15 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
contentEditable
onInput={updateContent}
onKeyDown={handleKeyDown}
dir="ltr"
className="min-h-[400px] outline-none prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: content }}
data-placeholder={placeholder || 'Commencez à écrire...'}
style={{
fontSize: '16px',
lineHeight: '1.6'
lineHeight: '1.6',
direction: 'ltr',
textAlign: 'left'
}}
/>
</CardContent>
+154 -3
View File
@@ -13,7 +13,7 @@ import { RichTextEditor } from "@/components/RichTextEditor";
import { MadeWithDyad } from "@/components/made-with-dyad";
import {
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon
} from "lucide-react";
import AuthService from "@/services/AuthService";
import AdminService from "@/services/AdminService";
@@ -21,6 +21,7 @@ import NotesService, { Note } from "@/services/NotesService";
import TodosService, { Todo } from "@/services/TodosService";
import RssService, { RssFeed, RssArticle } from "@/services/RssService";
import SettingsService, { Settings as AppSettings } from "@/services/SettingsService";
import CalendarService, { CalendarEvent } from "@/services/CalendarService";
import { useNavigate } from "react-router-dom";
import { showError, showSuccess } from "@/utils/toast";
@@ -40,6 +41,7 @@ const Index = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [rssFeeds, setRssFeeds] = useState<RssFeed[]>([]);
const [rssArticles, setRssArticles] = useState<RssArticle[]>([]);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [settings, setSettings] = useState<AppSettings>({});
const [adminTab, setAdminTab] = useState("users");
const [showArchived, setShowArchived] = useState(false);
@@ -53,6 +55,7 @@ const Index = () => {
loadTodos();
loadRssFeeds();
loadRssArticles();
loadCalendarEvents();
} else {
navigate("/login");
}
@@ -102,6 +105,17 @@ const Index = () => {
}
};
const loadCalendarEvents = async () => {
try {
const events = await CalendarService.getEvents(10);
if (Array.isArray(events)) {
setCalendarEvents(events);
}
} catch (error) {
console.error("Erreur lors du chargement des événements:", error);
}
};
const loadUsers = async () => {
if (user?.is_admin) {
try {
@@ -898,11 +912,80 @@ const Index = () => {
</div>
</CardContent>
</Card>
{/* Calendar Box */}
<Card className="shadow-lg">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-xl flex items-center gap-2">
<CalendarIcon className="h-6 w-6" />
Rendez-vous
</CardTitle>
<Button size="lg" variant="outline" onClick={async () => {
try {
const result = await CalendarService.sync();
showSuccess(`${result.syncedCount} événements synchronisés`);
await loadCalendarEvents();
} catch (error) {
showError("Erreur lors de la synchronisation");
}
}} className="gap-2">
<RefreshCw className="h-5 w-5" />
Synchroniser
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[480px] overflow-y-auto">
{calendarEvents.length > 0 ? (
calendarEvents.map(event => {
const startDate = new Date(event.start_time);
const endDate = new Date(event.end_time);
const isToday = startDate.toDateString() === new Date().toDateString();
return (
<div
key={event.id}
className="p-3 border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => event.html_link && window.open(event.html_link, '_blank')}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-medium line-clamp-2 mb-1">{event.title}</h4>
<p className="text-sm text-muted-foreground">
{isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR')} à {startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</p>
{event.location && (
<p className="text-xs text-muted-foreground mt-1">
📍 {event.location}
</p>
)}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
</div>
</div>
);
})
) : (
<p className="text-center text-muted-foreground py-8">
Aucun rendez-vous à venir
<br />
<Button
variant="link"
className="mt-2"
onClick={() => setShowAdmin(true)}
>
Configurer Google Calendar
</Button>
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
{/* Admin Modal */}
{/* Admin Modal */}
<Dialog open={showAdmin} onOpenChange={setShowAdmin}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -914,9 +997,10 @@ const Index = () => {
</DialogHeader>
<Tabs value={adminTab} onValueChange={setAdminTab}>
<TabsList className="grid grid-cols-4 w-full">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="users">Utilisateurs</TabsTrigger>
<TabsTrigger value="rss">Flux RSS</TabsTrigger>
<TabsTrigger value="calendar">Google Calendar</TabsTrigger>
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
<TabsTrigger value="settings">Paramètres</TabsTrigger>
</TabsList>
@@ -1003,6 +1087,73 @@ const Index = () => {
</div>
</TabsContent>
<TabsContent value="calendar" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Configuration Google Calendar
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="calendar-api-key">Clé API Google Calendar</Label>
<Input
id="calendar-api-key"
type="password"
placeholder="AIza..."
value={settings.google_calendar_api_key || ''}
onChange={(e) => setSettings({ ...settings, google_calendar_api_key: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
Créez une clé API sur{' '}
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
Google Cloud Console
</a>
{' '}et activez l'API Google Calendar
</p>
</div>
<div className="space-y-2">
<Label htmlFor="calendar-id">ID du calendrier</Label>
<Input
id="calendar-id"
placeholder="votre-email@gmail.com ou id-calendrier"
value={settings.google_calendar_id || ''}
onChange={(e) => setSettings({ ...settings, google_calendar_id: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
Trouvez l'ID du calendrier dans les paramètres de votre calendrier Google.<br />
Pour votre calendrier principal, utilisez votre adresse email.
</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveSettings} className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
Enregistrer
</Button>
<Button
variant="outline"
onClick={async () => {
try {
const result = await CalendarService.sync();
showSuccess(`${result.syncedCount} événements synchronisés`);
await loadCalendarEvents();
} catch (error) {
showError("Erreur lors de la synchronisation. Vérifiez votre configuration.");
}
}}
className="flex items-center gap-2"
>
<RefreshCw className="h-4 w-4" />
Tester la synchronisation
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="openrouter" className="mt-4 space-y-4">
<Card>
<CardHeader>
+62
View File
@@ -0,0 +1,62 @@
import axios from "axios";
import AuthService from "./AuthService";
export interface CalendarEvent {
id: number;
google_event_id: string;
title: string;
description?: string;
start_time: string;
end_time: string;
location?: string;
html_link?: string;
synced_at?: string;
}
class CalendarService {
private baseURL = "/api/calendar";
private getAuthHeader() {
return {
headers: {
Authorization: `Bearer ${AuthService.getToken()}`,
},
};
}
/**
* Récupérer les événements du calendrier
*/
async getEvents(limit: number = 10): Promise<CalendarEvent[]> {
try {
const response = await axios.get(`${this.baseURL}/events?limit=${limit}`, this.getAuthHeader());
return response.data;
} catch (error) {
console.error("Erreur lors de la récupération des événements:", error);
return [];
}
}
/**
* Synchroniser avec Google Calendar
*/
async sync(): Promise<{ syncedCount: number; events: number }> {
const response = await axios.post(`${this.baseURL}/sync`, {}, this.getAuthHeader());
return response.data;
}
/**
* Supprimer un événement local
*/
async deleteEvent(eventId: number): Promise<boolean> {
try {
await axios.delete(`${this.baseURL}/events/${eventId}`, this.getAuthHeader());
return true;
} catch (error) {
console.error("Erreur lors de la suppression de l'événement:", error);
return false;
}
}
}
export default new CalendarService();
+2
View File
@@ -5,6 +5,8 @@ export interface Settings {
ai_model?: string;
rss_refresh_interval?: number;
theme?: 'light' | 'dark' | 'auto';
google_calendar_api_key?: string;
google_calendar_id?: string;
}
class SettingsService {