diff --git a/config/database.js b/config/database.js index b5463ca..816b635 100644 --- a/config/database.js +++ b/config/database.js @@ -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'); diff --git a/package-lock.json b/package-lock.json index 98ae83f..0cf1e13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 744e003..e8d0648 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routes/calendar.routes.js b/routes/calendar.routes.js new file mode 100644 index 0000000..cb156b0 --- /dev/null +++ b/routes/calendar.routes.js @@ -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; diff --git a/routes/notes.routes.js b/routes/notes.routes.js index 1a29abf..13a32e2 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -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 diff --git a/server.js b/server.js index 905906b..33fdd24 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 8e327cb..a97e678 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -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' }} /> diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a1fb65b..9348abc 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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([]); const [rssFeeds, setRssFeeds] = useState([]); const [rssArticles, setRssArticles] = useState([]); + const [calendarEvents, setCalendarEvents] = useState([]); const [settings, setSettings] = useState({}); 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 = () => { + + {/* Calendar Box */} + + +
+ + + Rendez-vous + + +
+
+ +
+ {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 ( +
event.html_link && window.open(event.html_link, '_blank')} + > +
+
+

{event.title}

+

+ {isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR')} à {startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} +

+ {event.location && ( +

+ 📍 {event.location} +

+ )} +
+ +
+
+ ); + }) + ) : ( +

+ Aucun rendez-vous à venir +
+ +

+ )} +
+
+
- {/* Admin Modal */} {/* Admin Modal */} @@ -914,9 +997,10 @@ const Index = () => { - + Utilisateurs Flux RSS + Google Calendar OpenRouter Paramètres @@ -1003,6 +1087,73 @@ const Index = () => { + + + + + + Configuration Google Calendar + + + +
+ + setSettings({ ...settings, google_calendar_api_key: e.target.value })} + /> +

+ Créez une clé API sur{' '} + + Google Cloud Console + + {' '}et activez l'API Google Calendar +

+
+ +
+ + setSettings({ ...settings, google_calendar_id: e.target.value })} + /> +

+ Trouvez l'ID du calendrier dans les paramètres de votre calendrier Google.
+ Pour votre calendrier principal, utilisez votre adresse email. +

+
+ +
+ + +
+
+
+
+ diff --git a/src/services/CalendarService.ts b/src/services/CalendarService.ts new file mode 100644 index 0000000..4ebd91a --- /dev/null +++ b/src/services/CalendarService.ts @@ -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 { + 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 { + 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(); diff --git a/src/services/SettingsService.ts b/src/services/SettingsService.ts index dd27e22..8a66b32 100644 --- a/src/services/SettingsService.ts +++ b/src/services/SettingsService.ts @@ -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 {