diff --git a/.gitignore b/.gitignore index 2200de2..300911c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,9 @@ yarn-error.log* next-env.d.ts # Everclone data & uploads -data/ -attachments/ -uploads/ +/data/ +/attachments/ +/uploads/ *.enex *.zip +*.traineddata diff --git a/package-lock.json b/package-lock.json index 9d7a2c4..ef0a8f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,15 +40,18 @@ "mailparser": "^3.7.2", "marked": "^15.0.7", "next": "^15.2.0", + "pdf-parse": "^2.4.5", "react": "^19.0.0", "react-dom": "^19.0.0", "sax": "^1.4.1", + "tesseract.js": "^7.0.0", "turndown": "^7.2.0" }, "devDependencies": { "@types/jsdom": "^21.1.7", "@types/mailparser": "^3.4.6", "@types/node": "^22.13.5", + "@types/pdf-parse": "^1.1.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@types/sax": "^1.2.7", @@ -809,6 +812,205 @@ "node": ">=14.0.0" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/env": { "version": "15.5.28", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.28.tgz", @@ -1035,7 +1237,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -1048,14 +1250,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1069,14 +1271,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -1088,7 +1290,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -1120,7 +1322,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -1815,6 +2017,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pdf-parse": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz", + "integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -1906,6 +2118,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1991,6 +2216,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2042,7 +2273,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -2101,7 +2332,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -2117,7 +2348,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -2152,14 +2383,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -2260,7 +2491,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -2270,14 +2501,14 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -2375,7 +2606,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2388,7 +2619,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -2406,7 +2637,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -2469,14 +2700,14 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -2615,7 +2846,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -2783,6 +3014,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/imapflow": { "version": "1.7.8", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz", @@ -2904,6 +3141,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -2914,7 +3157,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3261,6 +3504,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3377,13 +3633,55 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.60", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", @@ -3423,7 +3721,7 @@ "version": "0.6.11", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.11.tgz", "integrity": "sha512-wrVBTLWDu7nTUKGWplQebWOXWnhaIwyKQhBxobH/CGxJHoVw27r9vG5ba7kdZnxG+f4rC6dZd6m2oHmpkOcO7g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -3441,7 +3739,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -3468,7 +3766,7 @@ "version": "2.0.12", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/on-exit-leak-free": { @@ -3480,6 +3778,15 @@ "node": ">=14.0.0" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -3528,9 +3835,41 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/peberminta": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", @@ -3544,7 +3883,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -3554,13 +3893,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -3617,7 +3956,7 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", @@ -3629,7 +3968,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/postcss": { @@ -3799,7 +4138,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4060,7 +4399,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -4104,7 +4443,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -4158,7 +4497,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -4177,6 +4516,12 @@ "node": ">= 12.13.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.13", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", @@ -4586,6 +4931,19 @@ "jiti": "bin/jiti.js" } }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tailwindcss/node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4599,6 +4957,30 @@ "node": ">=8.10.0" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -4644,7 +5026,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -4667,19 +5049,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tippy.js": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", @@ -4783,7 +5152,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4870,6 +5239,12 @@ "node": ">=18" } }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -4949,6 +5324,15 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index 5d75889..7bd9ed0 100644 --- a/package.json +++ b/package.json @@ -42,15 +42,18 @@ "mailparser": "^3.7.2", "marked": "^15.0.7", "next": "^15.2.0", + "pdf-parse": "^2.4.5", "react": "^19.0.0", "react-dom": "^19.0.0", "sax": "^1.4.1", + "tesseract.js": "^7.0.0", "turndown": "^7.2.0" }, "devDependencies": { "@types/jsdom": "^21.1.7", "@types/mailparser": "^3.4.6", "@types/node": "^22.13.5", + "@types/pdf-parse": "^1.1.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@types/sax": "^1.2.7", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 054f894..64f7a7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -30,36 +30,51 @@ model NotebookStack { } model Notebook { - id String @id @default(cuid()) - userId String @default("default") - name String - stackId String? - stack NotebookStack? @relation(fields: [stackId], references: [id], onDelete: SetNull) - isDefault Boolean @default(false) - isShortcut Boolean @default(false) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - notes Note[] + id String @id @default(cuid()) + userId String @default("default") + name String + stackId String? + stack NotebookStack? @relation(fields: [stackId], references: [id], onDelete: SetNull) + isDefault Boolean @default(false) + isShortcut Boolean @default(false) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + notes Note[] autoSortRules AutoSortRule[] + shares NotebookShare[] @@unique([userId, name]) @@index([userId]) @@index([stackId]) } +model NotebookShare { + id String @id @default(cuid()) + notebookId String + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + userId String // Collaborator user ID + role String @default("write") // "read" | "write" + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([notebookId, userId]) + @@index([userId]) + @@index([notebookId]) +} + model Note { - id String @id @default(cuid()) - userId String @default("default") - title String @default("Untitled Note") - contentHtml String @default("") - contentText String @default("") - excerpt String @default("") + id String @id @default(cuid()) + userId String @default("default") + title String @default("Untitled Note") + contentHtml String @default("") + contentText String @default("") + excerpt String @default("") notebookId String - notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) - isPinned Boolean @default(false) - isShortcut Boolean @default(false) - isTrashed Boolean @default(false) + notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade) + isPinned Boolean @default(false) + isShortcut Boolean @default(false) + isTrashed Boolean @default(false) trashedAt DateTime? thumbnailUrl String? sourceUrl String? @@ -67,11 +82,12 @@ model Note { latitude Float? longitude Float? reminderAt DateTime? - reminderDone Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) + reminderDone Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) tags NoteTag[] attachments Attachment[] + history NoteHistory[] @@index([userId, isTrashed, isPinned, updatedAt(sort: Desc)]) @@index([notebookId, isTrashed, updatedAt(sort: Desc)]) @@ -81,6 +97,21 @@ model Note { @@index([title]) } +model NoteHistory { + id String @id @default(cuid()) + noteId String + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + title String + contentHtml String + contentText String + excerpt String @default("") + wordCount Int @default(0) + createdBy String? + createdAt DateTime @default(now()) + + @@index([noteId, createdAt(sort: Desc)]) +} + model Tag { id String @id @default(cuid()) userId String @default("default") diff --git a/src/app/api/attachments/[id]/ocr/route.ts b/src/app/api/attachments/[id]/ocr/route.ts new file mode 100644 index 0000000..29ecd30 --- /dev/null +++ b/src/app/api/attachments/[id]/ocr/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { runOcrOnAttachment } from '@/lib/ocr'; + +export async function GET( + _req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const attachment = await prisma.attachment.findUnique({ + where: { id }, + select: { id: true, filename: true, mimeType: true, extractedText: true }, + }); + + if (!attachment) { + return NextResponse.json({ error: 'Attachment not found' }, { status: 404 }); + } + + return NextResponse.json({ + id: attachment.id, + filename: attachment.filename, + extractedText: attachment.extractedText, + }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch OCR text' }, + { status: 500 } + ); + } +} + +export async function POST( + _req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const result = await runOcrOnAttachment(id); + return NextResponse.json(result); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'OCR execution failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/attachments/[id]/route.ts b/src/app/api/attachments/[id]/route.ts new file mode 100644 index 0000000..3521ae3 --- /dev/null +++ b/src/app/api/attachments/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import { prisma } from '@/lib/prisma'; +import { getFullAttachmentPath } from '@/lib/storage'; + +export async function GET( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const attachment = await prisma.attachment.findUnique({ + where: { id }, + }); + + if (!attachment) { + return NextResponse.json({ error: 'Attachment not found' }, { status: 404 }); + } + + const fullPath = getFullAttachmentPath(attachment.storagePath); + const fileBuf = await fs.readFile(fullPath); + + const download = req.nextUrl.searchParams.get('download') === '1'; + const disposition = download ? 'attachment' : 'inline'; + const safeFilename = encodeURIComponent(attachment.filename); + + return new NextResponse(fileBuf, { + status: 200, + headers: { + 'Content-Type': attachment.mimeType || 'application/octet-stream', + 'Content-Length': String(fileBuf.length), + 'Content-Disposition': `${disposition}; filename*=UTF-8''${safeFilename}`, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to stream attachment' }, + { status: 404 } + ); + } +} + +export async function DELETE( + _req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + await prisma.attachment.delete({ where: { id } }); + return NextResponse.json({ deleted: true }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to delete attachment' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/attachments/ocr/route.ts b/src/app/api/attachments/ocr/route.ts new file mode 100644 index 0000000..87adb9e --- /dev/null +++ b/src/app/api/attachments/ocr/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { runOcrOnAttachment } from '@/lib/ocr'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + const limit = Math.min(parseInt(String(body.limit || '10'), 10) || 10, 50); + + // Find image or PDF attachments that do not have extractedText yet + const pendingAttachments = await prisma.attachment.findMany({ + where: { + extractedText: null, + OR: [ + { mimeType: { startsWith: 'image/' } }, + { mimeType: 'application/pdf' }, + { filename: { endsWith: '.pdf', mode: 'insensitive' } }, + { filename: { endsWith: '.jpg', mode: 'insensitive' } }, + { filename: { endsWith: '.jpeg', mode: 'insensitive' } }, + { filename: { endsWith: '.png', mode: 'insensitive' } }, + { filename: { endsWith: '.webp', mode: 'insensitive' } }, + ], + }, + take: limit, + select: { id: true, filename: true }, + }); + + const results = []; + for (const att of pendingAttachments) { + const res = await runOcrOnAttachment(att.id); + results.push({ id: att.id, filename: att.filename, success: res.success, hasText: Boolean(res.text) }); + } + + const remainingCount = await prisma.attachment.count({ + where: { + extractedText: null, + OR: [ + { mimeType: { startsWith: 'image/' } }, + { mimeType: 'application/pdf' }, + ], + }, + }); + + return NextResponse.json({ + processedCount: results.length, + remainingCount, + results, + }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Batch OCR failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/attachments/route.ts b/src/app/api/attachments/route.ts new file mode 100644 index 0000000..244f6ce --- /dev/null +++ b/src/app/api/attachments/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { saveAttachmentBuffer } from '@/lib/storage'; +import { enqueueAttachmentOcr } from '@/lib/ocr'; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const noteId = formData.get('noteId') as string | null; + if (!noteId) { + return NextResponse.json({ error: 'noteId is required' }, { status: 400 }); + } + + const files = formData.getAll('files') as File[]; + const singleFile = formData.get('file') as File | null; + if (singleFile && files.length === 0) { + files.push(singleFile); + } + + const created = []; + for (const file of files) { + const arrayBuf = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuf); + const mimeType = file.type || 'application/octet-stream'; + const filename = file.name || 'attachment'; + + const savedBlob = await saveAttachmentBuffer(buffer, filename, mimeType); + const attachment = await prisma.attachment.create({ + data: { + noteId, + filename, + mimeType, + sizeBytes: savedBlob.sizeBytes, + md5Hash: savedBlob.md5Hash, + storagePath: savedBlob.storagePath, + extractedText: savedBlob.extractedText, + }, + }); + created.push(attachment); + + if (!savedBlob.extractedText) { + enqueueAttachmentOcr(attachment.id); + } + } + + await prisma.note.update({ + where: { id: noteId }, + data: { updatedAt: new Date() }, + }); + + return NextResponse.json({ attachments: created }); + } catch (err: unknown) { + console.error('Upload attachment error:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to upload attachment' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notebooks/[id]/shares/route.ts b/src/app/api/notebooks/[id]/shares/route.ts new file mode 100644 index 0000000..5ec04f4 --- /dev/null +++ b/src/app/api/notebooks/[id]/shares/route.ts @@ -0,0 +1,216 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, getCurrentUserId } from '@/lib/prisma'; + +export async function GET( + _req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const userId = await getCurrentUserId(); + const { id: notebookId } = await context.params; + + // Verify notebook exists and user has access (either owner or shared) + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + include: { + shares: true, + }, + }); + + if (!notebook) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); + } + + const isOwner = notebook.userId === userId; + const isCollaborator = notebook.shares.some((s) => s.userId === userId); + + if (!isOwner && !isCollaborator) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Load collaborator user profiles + const userIds = notebook.shares.map((s) => s.userId); + const users = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true, avatarColor: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u])); + + const sharesWithUsers = notebook.shares.map((s) => ({ + ...s, + user: userMap.get(s.userId) || { + id: s.userId, + name: s.userId, + email: `${s.userId}@everclone.local`, + avatarColor: '#00a82d', + }, + })); + + return NextResponse.json({ + isOwner, + notebook: { + id: notebook.id, + name: notebook.name, + userId: notebook.userId, + }, + shares: sharesWithUsers, + }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch notebook shares' }, + { status: 500 } + ); + } +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const userId = await getCurrentUserId(); + const { id: notebookId } = await context.params; + const body = await req.json(); + + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + }); + + if (!notebook) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); + } + + // Only owner can share or modify shares + if (notebook.userId !== userId) { + return NextResponse.json( + { error: 'Only the notebook owner can manage shares' }, + { status: 403 } + ); + } + + let targetUserId = body.userId?.trim(); + const email = body.email?.trim()?.toLowerCase(); + const role = body.role === 'read' ? 'read' : 'write'; + + if (!targetUserId && email) { + let targetUser = await prisma.user.findUnique({ where: { email } }); + if (!targetUser) { + // Create user placeholder for email invitation + const username = email.split('@')[0]; + targetUser = await prisma.user.create({ + data: { + email, + name: username.charAt(0).toUpperCase() + username.slice(1), + }, + }); + } + targetUserId = targetUser.id; + } + + if (!targetUserId) { + return NextResponse.json( + { error: 'Target user ID or email is required' }, + { status: 400 } + ); + } + + if (targetUserId === userId) { + return NextResponse.json( + { error: 'Cannot share a notebook with yourself' }, + { status: 400 } + ); + } + + const share = await prisma.notebookShare.upsert({ + where: { + notebookId_userId: { + notebookId, + userId: targetUserId, + }, + }, + update: { role }, + create: { + notebookId, + userId: targetUserId, + role, + }, + }); + + const userProfile = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { id: true, name: true, email: true, avatarColor: true }, + }); + + return NextResponse.json({ + success: true, + share: { + ...share, + user: userProfile, + }, + }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to save share' }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const currentUserId = await getCurrentUserId(); + const { id: notebookId } = await context.params; + const targetUserId = req.nextUrl.searchParams.get('userId'); + const shareId = req.nextUrl.searchParams.get('shareId'); + + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId }, + }); + + if (!notebook) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); + } + + // Owner can delete any share; collaborator can remove themselves + const isOwner = notebook.userId === currentUserId; + + if (!shareId && !targetUserId) { + return NextResponse.json( + { error: 'shareId or userId required' }, + { status: 400 } + ); + } + + if (shareId) { + const share = await prisma.notebookShare.findUnique({ where: { id: shareId } }); + if (!share) { + return NextResponse.json({ deleted: true }); + } + if (!isOwner && share.userId !== currentUserId) { + return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); + } + await prisma.notebookShare.delete({ where: { id: shareId } }); + return NextResponse.json({ deleted: true }); + } + + if (targetUserId) { + if (!isOwner && targetUserId !== currentUserId) { + return NextResponse.json({ error: 'Permission denied' }, { status: 403 }); + } + await prisma.notebookShare.deleteMany({ + where: { notebookId, userId: targetUserId }, + }); + return NextResponse.json({ deleted: true }); + } + + return NextResponse.json({ deleted: true }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to remove share' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notes/[id]/history/route.ts b/src/app/api/notes/[id]/history/route.ts new file mode 100644 index 0000000..e473d9a --- /dev/null +++ b/src/app/api/notes/[id]/history/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { stripHtmlToText, makeExcerpt } from '@/lib/storage'; + +export async function GET( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const historyId = req.nextUrl.searchParams.get('historyId'); + + if (historyId) { + const snapshot = await prisma.noteHistory.findUnique({ + where: { id: historyId }, + }); + if (!snapshot) { + return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 }); + } + return NextResponse.json(snapshot); + } + + const history = await prisma.noteHistory.findMany({ + where: { noteId: id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + noteId: true, + title: true, + excerpt: true, + wordCount: true, + createdBy: true, + createdAt: true, + }, + take: 50, + }); + + return NextResponse.json({ history }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to load note history' }, + { status: 500 } + ); + } +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const body = await req.json(); + + const note = await prisma.note.findUnique({ + where: { id }, + }); + + if (!note) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + + // Restore action + if (body.action === 'restore') { + const historyId = body.historyId; + if (!historyId) { + return NextResponse.json({ error: 'historyId required' }, { status: 400 }); + } + + const snapshot = await prisma.noteHistory.findUnique({ + where: { id: historyId }, + }); + + if (!snapshot) { + return NextResponse.json({ error: 'Snapshot not found' }, { status: 404 }); + } + + // 1. Take a snapshot of current state before restoring + if (note.contentHtml) { + await prisma.noteHistory.create({ + data: { + noteId: id, + title: note.title, + contentHtml: note.contentHtml, + contentText: note.contentText, + excerpt: note.excerpt, + wordCount: note.contentText.trim().split(/\s+/).filter(Boolean).length, + createdBy: 'Before Restore', + }, + }); + } + + // 2. Restore the note to the snapshot + const restored = await prisma.note.update({ + where: { id }, + data: { + title: snapshot.title, + contentHtml: snapshot.contentHtml, + contentText: snapshot.contentText, + excerpt: snapshot.excerpt, + updatedAt: new Date(), + }, + include: { + notebook: true, + tags: { include: { tag: true } }, + attachments: { orderBy: { createdAt: 'asc' } }, + }, + }); + + return NextResponse.json({ + restored: true, + note: { + ...restored, + tags: restored.tags.map((nt) => nt.tag), + }, + }); + } + + // Manual snapshot action + const text = note.contentText || stripHtmlToText(note.contentHtml); + const snapshot = await prisma.noteHistory.create({ + data: { + noteId: id, + title: note.title, + contentHtml: note.contentHtml, + contentText: text, + excerpt: note.excerpt || makeExcerpt(text), + wordCount: text.trim().split(/\s+/).filter(Boolean).length, + createdBy: body.createdBy || 'Manual Snapshot', + }, + }); + + return NextResponse.json({ snapshot }); + } catch (err: unknown) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Note history operation failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 69c65e4..56eec3c 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; +import { prisma, getCurrentUserId } from '@/lib/prisma'; import { stripHtmlToText, makeExcerpt } from '@/lib/storage'; import { getOrCreateTagIds } from '@/lib/importers'; import { Prisma } from '@prisma/client'; @@ -9,11 +9,16 @@ export async function GET( context: { params: Promise<{ id: string }> } ) { try { + const userId = await getCurrentUserId(); const { id } = await context.params; const note = await prisma.note.findUnique({ where: { id }, include: { - notebook: true, + notebook: { + include: { + shares: true, + }, + }, tags: { include: { tag: true } }, attachments: { orderBy: { createdAt: 'asc' } }, }, @@ -23,8 +28,24 @@ export async function GET( return NextResponse.json({ error: 'Note not found' }, { status: 404 }); } + const hasAccess = + note.userId === userId || + note.notebook.userId === userId || + note.notebook.shares.some((s) => s.userId === userId); + + if (!hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const share = note.notebook.shares.find((s) => s.userId === userId); + const canEdit = + note.userId === userId || + note.notebook.userId === userId || + share?.role === 'write'; + return NextResponse.json({ ...note, + canEdit, tags: note.tags.map((nt) => nt.tag), }); } catch (err: unknown) { @@ -40,9 +61,32 @@ export async function PATCH( context: { params: Promise<{ id: string }> } ) { try { + const userId = await getCurrentUserId(); const { id } = await context.params; const body = await req.json(); + const existingNote = await prisma.note.findUnique({ + where: { id }, + include: { + notebook: { + include: { shares: true }, + }, + }, + }); + + if (!existingNote) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + + const canEdit = + existingNote.userId === userId || + existingNote.notebook.userId === userId || + existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write'); + + if (!canEdit) { + return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 }); + } + const data: Prisma.NoteUncheckedUpdateInput = {}; let shouldBumpUpdatedAt = false; @@ -57,6 +101,40 @@ export async function PATCH( data.excerpt = makeExcerpt(text); shouldBumpUpdatedAt = true; } + + // Automatic version history snapshotting (at most 1 snapshot per 5 minutes of editing) + if (body.contentHtml !== undefined || body.title !== undefined) { + try { + const lastSnapshot = await prisma.noteHistory.findFirst({ + where: { noteId: id }, + orderBy: { createdAt: 'desc' }, + select: { createdAt: true }, + }); + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + if (!lastSnapshot || lastSnapshot.createdAt < fiveMinutesAgo) { + const currentNote = await prisma.note.findUnique({ + where: { id }, + select: { title: true, contentHtml: true, contentText: true, excerpt: true }, + }); + if (currentNote && currentNote.contentHtml) { + const wordCount = currentNote.contentText.trim().split(/\s+/).filter(Boolean).length; + await prisma.noteHistory.create({ + data: { + noteId: id, + title: currentNote.title, + contentHtml: currentNote.contentHtml, + contentText: currentNote.contentText, + excerpt: currentNote.excerpt, + wordCount, + createdBy: 'Auto Snapshot', + }, + }); + } + } + } catch (historyErr) { + console.warn('Note history snapshot creation failed:', historyErr); + } + } if (body.notebookId !== undefined) { data.notebookId = body.notebookId; } @@ -138,9 +216,32 @@ export async function DELETE( context: { params: Promise<{ id: string }> } ) { try { + const userId = await getCurrentUserId(); const { id } = await context.params; const permanent = req.nextUrl.searchParams.get('permanent') === 'true'; + const existingNote = await prisma.note.findUnique({ + where: { id }, + include: { + notebook: { + include: { shares: true }, + }, + }, + }); + + if (!existingNote) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + + const canDelete = + existingNote.userId === userId || + existingNote.notebook.userId === userId || + existingNote.notebook.shares.some((s) => s.userId === userId && s.role === 'write'); + + if (!canDelete) { + return NextResponse.json({ error: 'Read-only access to this notebook' }, { status: 403 }); + } + if (permanent) { await prisma.note.delete({ where: { id } }); return NextResponse.json({ deleted: true, permanent: true }); diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index d487024..8627563 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -129,7 +129,21 @@ export async function POST(req: NextRequest) { } const defaultNb = await ensureDefaultNotebook(userId); - const notebookId = body.notebookId || defaultNb.id; + let notebookId = defaultNb.id; + if (body.notebookId) { + const allowedNb = await prisma.notebook.findFirst({ + where: { + id: body.notebookId, + OR: [ + { userId }, + { shares: { some: { userId, role: 'write' } } }, + ], + }, + }); + if (allowedNb) { + notebookId = allowedNb.id; + } + } const title = body.title?.trim() || 'Untitled Note'; const contentHtml = body.contentHtml || '
'; const contentText = stripHtmlToText(contentHtml); diff --git a/src/app/api/sidebar/route.ts b/src/app/api/sidebar/route.ts index f1de9e5..0277f17 100644 --- a/src/app/api/sidebar/route.ts +++ b/src/app/api/sidebar/route.ts @@ -10,7 +10,8 @@ export async function GET() { const [ users, stacks, - notebooks, + ownedNotebooks, + sharedNotebooks, tags, savedSearches, shortcutNotes, @@ -28,8 +29,20 @@ export async function GET() { }), prisma.notebook.findMany({ where: { userId }, + include: { + shares: { select: { id: true, userId: true, role: true } }, + }, orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }], }), + prisma.notebook.findMany({ + where: { + shares: { some: { userId } }, + }, + include: { + shares: { where: { userId } }, + }, + orderBy: [{ name: 'asc' }], + }), prisma.tag.findMany({ where: { userId }, orderBy: { name: 'asc' }, @@ -43,12 +56,20 @@ export async function GET() { select: { id: true, title: true, notebookId: true, updatedAt: true }, orderBy: { updatedAt: 'desc' }, }), - prisma.note.count({ where: { userId, isTrashed: false } }), + prisma.note.count({ + where: { + isTrashed: false, + OR: [{ userId }, { notebook: { shares: { some: { userId } } } }], + }, + }), prisma.note.count({ where: { userId, isTrashed: true } }), - // Fast single GROUP BY for notebook note counts + // Fast single GROUP BY for notebook note counts (owned + shared) prisma.note.groupBy({ by: ['notebookId'], - where: { userId, isTrashed: false }, + where: { + isTrashed: false, + OR: [{ userId }, { notebook: { shares: { some: { userId } } } }], + }, _count: { _all: true }, }), // Fast single raw GROUP BY query for tag note counts @@ -71,14 +92,48 @@ export async function GET() { tagCountMap.set(item.tagId, Number(item.count)); } + // Format owned notebooks + const formattedOwned = ownedNotebooks.map((nb) => ({ + id: nb.id, + name: nb.name, + userId: nb.userId, + stackId: nb.stackId, + isDefault: nb.isDefault, + isShortcut: nb.isShortcut, + sortOrder: nb.sortOrder, + noteCount: notebookCountMap.get(nb.id) || 0, + isOwner: true, + isShared: nb.shares.length > 0, + sharesCount: nb.shares.length, + shareRole: 'owner' as const, + })); + + // Format shared notebooks + const formattedShared = sharedNotebooks.map((nb) => { + const myShare = nb.shares[0]; + return { + id: nb.id, + name: nb.name, + userId: nb.userId, + stackId: null, // Shared notebooks appear in their own section or unstacked + isDefault: false, + isShortcut: nb.isShortcut, + sortOrder: nb.sortOrder, + noteCount: notebookCountMap.get(nb.id) || 0, + isOwner: false, + isShared: true, + sharesCount: 1, + shareRole: (myShare?.role || 'read') as 'read' | 'write', + }; + }); + + const combinedNotebooks = [...formattedOwned, ...formattedShared]; + return NextResponse.json({ currentUser, users, stacks, - notebooks: notebooks.map((nb) => ({ - ...nb, - noteCount: notebookCountMap.get(nb.id) || 0, - })), + notebooks: combinedNotebooks, tags: tags.map((t) => ({ ...t, noteCount: tagCountMap.get(t.id) || 0, @@ -86,8 +141,8 @@ export async function GET() { savedSearches, shortcuts: { notes: shortcutNotes, - notebooks: notebooks.filter((n) => n.isShortcut).map(nb => ({ ...nb, noteCount: notebookCountMap.get(nb.id) || 0 })), - tags: tags.filter((t) => t.isShortcut).map(t => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })), + notebooks: combinedNotebooks.filter((n) => n.isShortcut), + tags: tags.filter((t) => t.isShortcut).map((t) => ({ ...t, noteCount: tagCountMap.get(t.id) || 0 })), savedSearches: savedSearches.filter((s) => s.isShortcut), }, counts: { diff --git a/src/components/EvercloneWorkspace.tsx b/src/components/EvercloneWorkspace.tsx index dd43266..7723b57 100644 --- a/src/components/EvercloneWorkspace.tsx +++ b/src/components/EvercloneWorkspace.tsx @@ -45,6 +45,8 @@ import { ArrowRight, Link2, GitMerge, + History, + Users, } from 'lucide-react'; import { SidebarData, @@ -57,6 +59,8 @@ import SettingsView from './SettingsView'; import ThumbnailEditorModal from './ThumbnailEditorModal'; import TriageModal from './TriageModal'; import MergeNotebookModal from './MergeNotebookModal'; +import ShareNotebookModal from './ShareNotebookModal'; +import NoteHistoryModal from './NoteHistoryModal'; import { PdfAutoThumbnail, InlinePdfViewerCard } from './PdfNoteComponents'; export default function EvercloneWorkspace() { @@ -247,6 +251,13 @@ export default function EvercloneWorkspace() { const [mergeModalOpen, setMergeModalOpen] = useState(false); const [mergeSourceNotebook, setMergeSourceNotebook] = useState