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(null); + // Share Notebooks modal + const [shareModalOpen, setShareModalOpen] = useState(false); + const [shareNotebookTarget, setShareNotebookTarget] = useState(null); + + // Note Version History modal + const [historyModalOpen, setHistoryModalOpen] = useState(false); + const searchInputRef = useRef(null); const saveTimerRef = useRef(null); const listParentRef = useRef(null); @@ -658,6 +669,23 @@ export default function EvercloneWorkspace() { }; }, [selectedNoteId]); + const reloadActiveNote = useCallback( + async (noteId?: string) => { + const targetId = noteId || selectedNoteId; + if (!targetId) return; + try { + const res = await fetch(`/api/notes/${targetId}`); + if (!res.ok) return; + const data: NoteDetail = await res.json(); + setActiveNote(data); + setSaveState('saved'); + } catch (e) { + console.error('Error reloading note detail:', e); + } + }, + [selectedNoteId] + ); + // Auto-save note edits with debounce const scheduleNoteSave = useCallback( (noteId: string, patch: Record) => { @@ -1515,6 +1543,10 @@ export default function EvercloneWorkspace() { setMergeSourceNotebook(item); setMergeModalOpen(true); }} + onShare={(item) => { + setShareNotebookTarget(item); + setShareModalOpen(true); + }} onDeleted={() => { if (selectedNotebookId === nb.id) { setSelectedNotebookId(''); @@ -1548,6 +1580,10 @@ export default function EvercloneWorkspace() { setMergeSourceNotebook(item); setMergeModalOpen(true); }} + onShare={(item) => { + setShareNotebookTarget(item); + setShareModalOpen(true); + }} onDeleted={() => { if (selectedNotebookId === nb.id) { setSelectedNotebookId(''); @@ -3093,6 +3129,15 @@ export default function EvercloneWorkspace() { + + {/* Export Menu */}
+ ) : null} {isImg && !activeNote.isTrashed && ( + {onShare && ( + + )} {canMerge && onMerge && ( + + +
+ + + {error && ( +
+ + {error} +
+ )} + + {/* 2-Pane Body */} +
+ {/* Left Timeline Pane */} +
+
+ Saved Snapshots ({historyList.length}) +
+ +
+ {loadingList ? ( +
Loading version history…
+ ) : historyList.length === 0 ? ( +
+ +

No previous snapshots yet

+

+ Snapshots are automatically saved while editing, or tap "Take Snapshot Now" above. +

+
+ ) : ( + historyList.map((item) => { + const isSelected = selectedSnapshot?.id === item.id; + const date = new Date(item.createdAt); + return ( + + ); + }) + )} +
+
+ + {/* Right Snapshot Preview Pane */} +
+ {loadingDetail ? ( +
+ + Loading snapshot preview… +
+ ) : selectedSnapshot ? ( + <> +
+
+

{selectedSnapshot.title}

+

+ Captured on {format(new Date(selectedSnapshot.createdAt), 'MMMM d, yyyy · h:mm:ss a')} · {selectedSnapshot.wordCount} words +

+
+ + +
+ +
+ {selectedSnapshot.contentHtml ? ( +
+ ) : ( +
+                      {selectedSnapshot.contentText || '(Empty snapshot)'}
+                    
+ )} +
+ + ) : ( +
+ Select a version from the timeline on the left to preview it. +
+ )} +
+
+
+ + ); +} diff --git a/src/components/ShareNotebookModal.tsx b/src/components/ShareNotebookModal.tsx new file mode 100644 index 0000000..cc30a27 --- /dev/null +++ b/src/components/ShareNotebookModal.tsx @@ -0,0 +1,300 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Users, X, UserPlus, Trash2, Shield, Eye, Edit3, Loader2 } from 'lucide-react'; +import { NotebookItem, NotebookShareItem, UserItem } from '@/types/everclone'; + +interface ShareNotebookModalProps { + open: boolean; + onClose: () => void; + notebook: NotebookItem | null; + users: UserItem[]; + currentUser?: UserItem; + onSharesChanged?: () => void; +} + +export default function ShareNotebookModal({ + open, + onClose, + notebook, + users, + currentUser, + onSharesChanged, +}: ShareNotebookModalProps) { + const [shares, setShares] = useState([]); + const [loading, setLoading] = useState(false); + const [sharing, setSharing] = useState(false); + const [selectedUserId, setSelectedUserId] = useState(''); + const [customEmail, setCustomEmail] = useState(''); + const [selectedRole, setSelectedRole] = useState<'write' | 'read'>('write'); + const [error, setError] = useState(null); + + // Other users eligible to be added + const eligibleUsers = users.filter( + (u) => u.id !== currentUser?.id && u.id !== notebook?.userId + ); + + const loadShares = async () => { + if (!notebook) return; + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/notebooks/${notebook.id}/shares`); + if (!res.ok) throw new Error('Failed to load shares'); + const data = await res.json(); + setShares(data.shares || []); + } catch (e) { + console.error(e); + setError('Could not load notebook shares.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (open && notebook) { + loadShares(); + setCustomEmail(''); + if (eligibleUsers.length > 0) { + setSelectedUserId(eligibleUsers[0].id); + } + } + }, [open, notebook?.id]); + + if (!open || !notebook) return null; + + const handleAddShare = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSharing(true); + + try { + const payload: { userId?: string; email?: string; role: 'read' | 'write' } = { + role: selectedRole, + }; + + if (customEmail.trim()) { + payload.email = customEmail.trim(); + } else if (selectedUserId) { + payload.userId = selectedUserId; + } else { + throw new Error('Please select a user or enter an email address.'); + } + + const res = await fetch(`/api/notebooks/${notebook.id}/shares`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to share notebook'); + + setCustomEmail(''); + await loadShares(); + if (onSharesChanged) onSharesChanged(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Error sharing notebook'); + } finally { + setSharing(false); + } + }; + + const handleRemoveShare = async (shareId: string) => { + if (!window.confirm('Revoke access for this collaborator?')) return; + try { + const res = await fetch(`/api/notebooks/${notebook.id}/shares?shareId=${shareId}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error('Failed to remove share'); + await loadShares(); + if (onSharesChanged) onSharesChanged(); + } catch (err) { + console.error(err); + setError('Failed to remove collaborator.'); + } + }; + + const handleUpdateRole = async (shareUserId: string, newRole: 'read' | 'write') => { + try { + const res = await fetch(`/api/notebooks/${notebook.id}/shares`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: shareUserId, role: newRole }), + }); + if (!res.ok) throw new Error('Failed to update role'); + await loadShares(); + if (onSharesChanged) onSharesChanged(); + } catch (err) { + console.error(err); + setError('Failed to update permission.'); + } + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Share Notebook

+

+ Collaborate on "{notebook.name}" +

+
+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Add collaborator form */} +
+ + +
+
+ {eligibleUsers.length > 0 ? ( + + ) : ( + setCustomEmail(e.target.value)} + placeholder="Collaborator email…" + className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-blue-500" + /> + )} +
+ +
+ +
+
+ + +
+ + {/* Current Collaborators */} +
+

Who has access

+ + {loading ? ( +
Loading collaborators…
+ ) : shares.length === 0 ? ( +
+ This notebook is private to you. +
+ ) : ( +
+ {shares.map((s) => ( +
+
+
+ {s.user?.name ? s.user.name.charAt(0).toUpperCase() : 'U'} +
+
+
{s.user?.name || s.userId}
+
{s.user?.email || ''}
+
+
+ +
+ + + +
+
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/lib/email.ts b/src/lib/email.ts index ec38416..1f6d855 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -3,6 +3,7 @@ import { simpleParser } from 'mailparser'; import { prisma, ensureDefaultNotebook, getCurrentUserId } from './prisma'; import { getOrCreateNotebookByName, getOrCreateTagIds } from './importers'; import { saveAttachmentBuffer, stripHtmlToText, makeExcerpt } from './storage'; +import { enqueueAttachmentOcr } from './ocr'; /** * Parses Evernote's Email-to-Note Subject line syntax: @@ -149,6 +150,10 @@ export async function ingestEmailToNote( }, }); + if (!savedBlob.extractedText) { + enqueueAttachmentOcr(createdAtt.id); + } + const attUrl = `/api/attachments/${createdAtt.id}`; if (att.cid && bodyHtml.includes(`cid:${att.cid}`)) { bodyHtml = bodyHtml.split(`cid:${att.cid}`).join(attUrl); diff --git a/src/lib/importers.ts b/src/lib/importers.ts index a1da206..93f7a03 100644 --- a/src/lib/importers.ts +++ b/src/lib/importers.ts @@ -9,6 +9,7 @@ import { makeExcerpt, guessExtensionFromMime, } from './storage'; +import { enqueueAttachmentOcr } from './ocr'; export function parseEvernoteTimestamp(ts?: string | null): Date | null { if (!ts) return null; @@ -212,6 +213,10 @@ export async function saveParsedEnexNote( }, }); + if (!savedBlob.extractedText) { + enqueueAttachmentOcr(attachment.id); + } + mediaByHash.set(savedBlob.md5Hash.toLowerCase(), { id: attachment.id, filename: attachment.filename, @@ -651,6 +656,7 @@ export async function localizeClippedImages( storagePath: savedBlob.storagePath, }, }); + enqueueAttachmentOcr(att.id); urlToLocalSrc.set(remoteUrl, `/api/attachments/${att.id}`); } catch { // Keep remote URL if download fails diff --git a/src/lib/ocr.ts b/src/lib/ocr.ts new file mode 100644 index 0000000..dce44ba --- /dev/null +++ b/src/lib/ocr.ts @@ -0,0 +1,141 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { createWorker } from 'tesseract.js'; +import { prisma } from './prisma'; +import { getFullAttachmentPath } from './storage'; + +// Safe lazy import of pdf-parse +async function extractPdfText(buffer: Buffer): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { PDFParse } = require('pdf-parse'); + const parser = new PDFParse({ data: buffer }); + const res = await parser.getText(); + const text = typeof res === 'string' ? res : res?.text || ''; + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 100000) : null; + } catch (err) { + console.warn('PDF digital text extraction skipped/failed:', err); + return null; + } +} + +/** + * Recognizes text inside an image using Tesseract OCR. + */ +export async function recognizeImageOcr(imageBufferOrPath: Buffer | string): Promise { + let worker: Tesseract.Worker | null = null; + try { + worker = await createWorker('eng'); + const ret = await worker.recognize(imageBufferOrPath); + const text = ret.data?.text?.trim() || ''; + return text.length > 0 ? text.slice(0, 50000) : null; + } catch (err) { + console.error('Tesseract OCR error:', err); + return null; + } finally { + if (worker) { + try { + await worker.terminate(); + } catch {} + } + } +} + +/** + * Extracts searchable text from any supported attachment buffer (Text, Markdown, PDF, Image OCR). + */ +export async function extractTextFromAttachment( + buffer: Buffer, + mimeType: string, + filename: string +): Promise { + const lowerMime = mimeType.toLowerCase(); + const lowerName = filename.toLowerCase(); + + // 1. Plain text / Markdown / CSV / JSON + if ( + lowerMime.startsWith('text/') || + lowerMime === 'application/json' || + lowerMime === 'application/xml' || + lowerName.endsWith('.md') || + lowerName.endsWith('.txt') || + lowerName.endsWith('.csv') || + lowerName.endsWith('.json') + ) { + return buffer.toString('utf-8').slice(0, 50000).trim() || null; + } + + // 2. Digital PDF extraction + if (lowerMime === 'application/pdf' || lowerName.endsWith('.pdf')) { + const pdfText = await extractPdfText(buffer); + if (pdfText) return pdfText; + } + + // 3. Image OCR (JPEG, PNG, WebP, BMP, TIFF) + if ( + lowerMime.startsWith('image/') || + lowerName.endsWith('.jpg') || + lowerName.endsWith('.jpeg') || + lowerName.endsWith('.png') || + lowerName.endsWith('.webp') || + lowerName.endsWith('.bmp') || + lowerName.endsWith('.tiff') + ) { + return await recognizeImageOcr(buffer); + } + + return null; +} + +/** + * Runs OCR / text extraction on an existing database Attachment record and updates its extractedText field. + */ +export async function runOcrOnAttachment( + attachmentId: string +): Promise<{ text: string | null; success: boolean }> { + try { + const attachment = await prisma.attachment.findUnique({ + where: { id: attachmentId }, + }); + + if (!attachment) { + return { text: null, success: false }; + } + + const fullPath = getFullAttachmentPath(attachment.storagePath); + const buffer = await fs.readFile(fullPath); + + const extracted = await extractTextFromAttachment( + buffer, + attachment.mimeType, + attachment.filename + ); + + if (extracted && extracted.trim()) { + await prisma.attachment.update({ + where: { id: attachmentId }, + data: { extractedText: extracted.trim() }, + }); + return { text: extracted.trim(), success: true }; + } + + return { text: null, success: true }; + } catch (err) { + console.error(`runOcrOnAttachment failed for ${attachmentId}:`, err); + return { text: null, success: false }; + } +} + +/** + * Asynchronously enqueues an attachment for background OCR without blocking callers. + */ +export function enqueueAttachmentOcr(attachmentId: string): void { + setTimeout(async () => { + try { + await runOcrOnAttachment(attachmentId); + } catch (e) { + console.warn(`Background OCR for attachment ${attachmentId} failed:`, e); + } + }, 50); +} diff --git a/src/lib/search.ts b/src/lib/search.ts index c291f08..804561d 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -111,7 +111,25 @@ export function buildNoteWhereClause(params: { const andConditions: Prisma.NoteWhereInput[] = []; if (params.userId) { - andConditions.push({ userId: params.userId }); + if (params.notebookId) { + andConditions.push({ + notebookId: params.notebookId, + OR: [ + { userId: params.userId }, + { notebook: { userId: params.userId } }, + { notebook: { shares: { some: { userId: params.userId } } } }, + ], + }); + } else { + andConditions.push({ + OR: [ + { userId: params.userId }, + { notebook: { shares: { some: { userId: params.userId } } } }, + ], + }); + } + } else if (params.notebookId) { + andConditions.push({ notebookId: params.notebookId }); } andConditions.push({ @@ -126,10 +144,6 @@ export function buildNoteWhereClause(params: { andConditions.push({ isPinned: true }); } - if (params.notebookId) { - andConditions.push({ notebookId: params.notebookId }); - } - if (params.stackId) { andConditions.push({ notebook: { stackId: params.stackId }, diff --git a/src/types/everclone.ts b/src/types/everclone.ts index 34f9de8..c15ae00 100644 --- a/src/types/everclone.ts +++ b/src/types/everclone.ts @@ -16,11 +16,42 @@ export interface NotebookStackItem { export interface NotebookItem { id: string; name: string; + userId?: string; stackId: string | null; isDefault: boolean; isShortcut: boolean; sortOrder: number; noteCount: number; + isShared?: boolean; + isOwner?: boolean; + shareRole?: 'owner' | 'write' | 'read'; + sharesCount?: number; +} + +export interface NotebookShareItem { + id: string; + notebookId: string; + userId: string; + role: 'read' | 'write'; + createdAt: string; + user?: { + id: string; + name: string; + email: string; + avatarColor: string; + }; +} + +export interface NoteHistoryItem { + id: string; + noteId: string; + title: string; + excerpt: string; + wordCount: number; + createdBy?: string | null; + createdAt: string; + contentHtml?: string; + contentText?: string; } export interface SavedSearchItem { @@ -39,6 +70,7 @@ export interface AttachmentItem { md5Hash: string; width?: number | null; height?: number | null; + extractedText?: string | null; createdAt: string; } @@ -81,6 +113,7 @@ export interface NoteDetail extends NoteSummary { latitude: number | null; longitude: number | null; attachments: AttachmentItem[]; + canEdit?: boolean; } export interface UserItem {