Add Image OCR search, Note Version History & Shared Notebooks

This commit is contained in:
chrisf committed 2026-10-22 22:48:36 -06:00
1 parent 247b63691a
commit 969b6ef083
21 files changed
+2166 -100

No files matched your search

+4 -3
View File
@@ -31,8 +31,9 @@ yarn-error.log*
next-env.d.ts
# Everclone data & uploads
data/
attachments/
uploads/
/data/
/attachments/
/uploads/
*.enex
*.zip
*.traineddata
+437 -53
View File
@@ -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": "*"
}
}
}
}
+3
View File
@@ -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",
+55 -24
View File
@@ -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")
+47
View File
@@ -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 }
);
}
}
+58
View File
@@ -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 }
);
}
}
+55
View File
@@ -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 }
);
}
}
+59
View File
@@ -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 }
);
}
}
+216
View File
@@ -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 }
);
}
}
+140
View File
@@ -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 }
);
}
}
+103 -2
View File
@@ -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 });
+15 -1
View File
@@ -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 || '<p></p>';
const contentText = stripHtmlToText(contentHtml);
+65 -10
View File
@@ -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: {
+131 -2
View File
@@ -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<NotebookItem | null>(null);
// Share Notebooks modal
const [shareModalOpen, setShareModalOpen] = useState(false);
const [shareNotebookTarget, setShareNotebookTarget] = useState<NotebookItem | null>(null);
// Note Version History modal
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
const listParentRef = useRef<HTMLDivElement>(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<string, unknown>) => {
@@ -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() {
<Info className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setHistoryModalOpen(true)}
title="Note Version History & Snapshots"
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"
>
<History className="w-4 h-4" />
</button>
{/* Export Menu */}
<div className="relative">
<button
@@ -3200,12 +3245,20 @@ export default function EvercloneWorkspace() {
</div>
)}
{/* Read-Only Notice for Shared Notes */}
{activeNote.canEdit === false && (
<div className="mx-6 mt-3 px-3 py-1.5 bg-amber-50 border border-amber-200 text-amber-800 rounded-md text-xs flex items-center gap-2">
<Users className="w-4 h-4 shrink-0 text-amber-600" />
<span>You have <strong>view-only</strong> access to this shared note.</span>
</div>
)}
{/* Note Title Input */}
<div className="px-6 pt-5 pb-2">
<input
type="text"
value={activeNote.title}
readOnly={activeNote.isTrashed}
readOnly={activeNote.isTrashed || activeNote.canEdit === false}
onChange={(e) => {
const newTitle = e.target.value;
setActiveNote({ ...activeNote, title: newTitle });
@@ -3220,7 +3273,7 @@ export default function EvercloneWorkspace() {
<RichNoteEditor
noteId={activeNote.id}
contentHtml={activeNote.contentHtml}
readOnly={activeNote.isTrashed}
readOnly={activeNote.isTrashed || activeNote.canEdit === false}
onChange={(newHtml) => {
setActiveNote((prev) =>
prev ? { ...prev, contentHtml: newHtml } : prev
@@ -3314,6 +3367,33 @@ export default function EvercloneWorkspace() {
<span className="text-[10px] text-gray-400">
{(att.sizeBytes / 1024).toFixed(1)} KB
</span>
{att.extractedText ? (
<span
className="px-1.5 py-0.5 text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200 rounded cursor-help"
title={`Extracted Text (OCR):\n${att.extractedText.slice(0, 300)}${att.extractedText.length > 300 ? '...' : ''}`}
>
OCR
</span>
) : isImg ? (
<button
type="button"
onClick={async () => {
try {
const res = await fetch(`/api/attachments/${att.id}/ocr`, { method: 'POST' });
const data = await res.json();
if (data.extractedText) {
reloadActiveNote(activeNote.id);
}
} catch (e) {
console.error(e);
}
}}
className="px-1.5 py-0.5 text-[9px] text-gray-400 hover:text-emerald-700 hover:bg-emerald-50 border border-gray-200 rounded"
title="Click to run OCR on this image now"
>
+OCR
</button>
) : null}
{isImg && !activeNote.isTrashed && (
<button
type="button"
@@ -3441,6 +3521,32 @@ export default function EvercloneWorkspace() {
stacks={sidebar.stacks}
onMerged={handleMergedNotebook}
/>
{shareNotebookTarget && (
<ShareNotebookModal
open={shareModalOpen}
onClose={() => {
setShareModalOpen(false);
setShareNotebookTarget(null);
}}
notebook={shareNotebookTarget}
users={sidebar.users || []}
currentUser={sidebar.currentUser}
onSharesChanged={fetchSidebar}
/>
)}
{activeNote && (
<NoteHistoryModal
open={historyModalOpen}
onClose={() => setHistoryModalOpen(false)}
note={activeNote}
onRestored={(updatedNote) => {
setActiveNote(updatedNote);
fetchNotesList();
}}
/>
)}
</>
)}
{/* Floating PWA Install Banner on Mobile */}
@@ -3635,6 +3741,7 @@ function NotebookRow({
onDropNote,
onRefresh,
onMerge,
onShare,
onDeleted,
canMerge,
}: {
@@ -3644,6 +3751,7 @@ function NotebookRow({
onDropNote?: (noteId: string) => void;
onRefresh: () => void;
onMerge?: (nb: NotebookItem) => void;
onShare?: (nb: NotebookItem) => void;
onDeleted?: () => void;
canMerge?: boolean;
}) {
@@ -3682,6 +3790,14 @@ function NotebookRow({
>
<Book className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="truncate">{nb.name}</span>
{nb.isShared && (
<span
title={nb.isOwner === false ? `Shared with you (${nb.shareRole ?? 'read'})` : 'Shared notebook'}
className="flex items-center text-blue-400 shrink-0"
>
<Users className="w-3 h-3" />
</span>
)}
{nb.isDefault && (
<span className="text-[9px] uppercase px-1 py-0.2 bg-neutral-700 text-emerald-400 rounded">
Default
@@ -3726,6 +3842,19 @@ function NotebookRow({
>
Rename Notebook…
</button>
{onShare && (
<button
type="button"
onClick={() => {
setMenuOpen(false);
onShare(nb);
}}
className="w-full text-left px-3 py-1.5 hover:bg-neutral-800 flex items-center gap-2 text-blue-400 hover:text-blue-300"
>
<Users className="w-3.5 h-3.5" />
<span>Share Notebook…</span>
</button>
)}
{canMerge && onMerge && (
<button
type="button"
+274
View File
@@ -0,0 +1,274 @@
'use client';
import React, { useState, useEffect } from 'react';
import { History, X, RotateCcw, Camera, Clock, FileText, Loader2, AlertCircle } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { NoteDetail, NoteHistoryItem } from '@/types/everclone';
interface NoteHistoryModalProps {
open: boolean;
onClose: () => void;
note: NoteDetail | null;
onRestored: (updatedNote: NoteDetail) => void;
}
export default function NoteHistoryModal({
open,
onClose,
note,
onRestored,
}: NoteHistoryModalProps) {
const [historyList, setHistoryList] = useState<NoteHistoryItem[]>([]);
const [selectedSnapshot, setSelectedSnapshot] = useState<NoteHistoryItem | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [loadingDetail, setLoadingDetail] = useState(false);
const [restoring, setRestoring] = useState(false);
const [takingSnapshot, setTakingSnapshot] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadHistory = async () => {
if (!note) return;
setLoadingList(true);
setError(null);
try {
const res = await fetch(`/api/notes/${note.id}/history`);
if (!res.ok) throw new Error('Failed to load history');
const data = await res.json();
const items: NoteHistoryItem[] = data.history || [];
setHistoryList(items);
if (items.length > 0) {
loadSnapshotDetail(items[0].id);
} else {
setSelectedSnapshot(null);
}
} catch (err) {
console.error(err);
setError('Could not load version history.');
} finally {
setLoadingList(false);
}
};
const loadSnapshotDetail = async (historyId: string) => {
if (!note) return;
setLoadingDetail(true);
try {
const res = await fetch(`/api/notes/${note.id}/history?historyId=${historyId}`);
if (!res.ok) throw new Error('Failed to load snapshot details');
const detail: NoteHistoryItem = await res.json();
setSelectedSnapshot(detail);
} catch (err) {
console.error(err);
} finally {
setLoadingDetail(false);
}
};
useEffect(() => {
if (open && note) {
loadHistory();
}
}, [open, note?.id]);
if (!open || !note) return null;
const handleTakeSnapshot = async () => {
setTakingSnapshot(true);
try {
const res = await fetch(`/api/notes/${note.id}/history`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'snapshot', createdBy: 'Manual' }),
});
if (!res.ok) throw new Error('Failed to take snapshot');
await loadHistory();
} catch (err) {
console.error(err);
setError('Failed to capture snapshot.');
} finally {
setTakingSnapshot(false);
}
};
const handleRestore = async (historyId: string) => {
if (!window.confirm('Restore this version? A snapshot of your current note will be saved first so nothing is lost.')) {
return;
}
setRestoring(true);
try {
const res = await fetch(`/api/notes/${note.id}/history`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restore', historyId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to restore snapshot');
onRestored(data.note);
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Error restoring version');
} finally {
setRestoring(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
<div className="relative w-full max-w-4xl h-[85vh] max-h-[750px] bg-neutral-900 border border-neutral-700 rounded-2xl shadow-2xl flex flex-col overflow-hidden text-neutral-100">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/90 shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20">
<History className="w-5 h-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Note Version History</h2>
<p className="text-xs text-neutral-400 truncate max-w-md">
Snapshots and previous edits for "{note.title}"
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleTakeSnapshot}
disabled={takingSnapshot}
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 rounded-xl text-xs font-medium border border-neutral-700 transition disabled:opacity-50"
title="Save a snapshot of the current note state"
>
{takingSnapshot ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Camera className="w-3.5 h-3.5" />
)}
<span>Take Snapshot Now</span>
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{error && (
<div className="mx-6 mt-4 p-3 bg-red-950/60 border border-red-800/80 rounded-xl text-xs text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0" />
<span>{error}</span>
</div>
)}
{/* 2-Pane Body */}
<div className="flex-1 flex overflow-hidden">
{/* Left Timeline Pane */}
<div className="w-72 sm:w-80 border-r border-neutral-800 flex flex-col bg-neutral-900/50">
<div className="p-3 border-b border-neutral-800/80 text-[11px] font-semibold text-neutral-400 uppercase tracking-wider">
Saved Snapshots ({historyList.length})
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{loadingList ? (
<div className="p-6 text-center text-xs text-neutral-500">Loading version history…</div>
) : historyList.length === 0 ? (
<div className="p-6 text-center space-y-2">
<Clock className="w-8 h-8 text-neutral-600 mx-auto" />
<p className="text-xs text-neutral-400 font-medium">No previous snapshots yet</p>
<p className="text-[11px] text-neutral-500">
Snapshots are automatically saved while editing, or tap "Take Snapshot Now" above.
</p>
</div>
) : (
historyList.map((item) => {
const isSelected = selectedSnapshot?.id === item.id;
const date = new Date(item.createdAt);
return (
<button
key={item.id}
type="button"
onClick={() => loadSnapshotDetail(item.id)}
className={`w-full text-left p-3 rounded-xl transition flex flex-col gap-1 border ${
isSelected
? 'bg-purple-950/40 border-purple-500/50 text-white shadow'
: 'bg-neutral-800/30 border-transparent text-neutral-300 hover:bg-neutral-800/80 hover:text-white'
}`}
>
<div className="flex items-center justify-between text-xs font-semibold">
<span className="truncate">{item.title || 'Untitled Note'}</span>
<span className="text-[10px] text-neutral-400 font-normal shrink-0">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
</div>
<div className="text-[11px] text-neutral-400 flex items-center justify-between">
<span>{format(date, 'MMM d, yyyy · h:mm a')}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-neutral-800 text-neutral-300">
{item.wordCount} words
</span>
</div>
</button>
);
})
)}
</div>
</div>
{/* Right Snapshot Preview Pane */}
<div className="flex-1 flex flex-col bg-neutral-950 overflow-hidden">
{loadingDetail ? (
<div className="flex-1 flex items-center justify-center text-xs text-neutral-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading snapshot preview…
</div>
) : selectedSnapshot ? (
<>
<div className="px-6 py-3.5 border-b border-neutral-800 bg-neutral-900/60 flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-semibold text-white">{selectedSnapshot.title}</h3>
<p className="text-[11px] text-neutral-400">
Captured on {format(new Date(selectedSnapshot.createdAt), 'MMMM d, yyyy · h:mm:ss a')} · {selectedSnapshot.wordCount} words
</p>
</div>
<button
type="button"
onClick={() => handleRestore(selectedSnapshot.id)}
disabled={restoring}
className="flex items-center gap-1.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-xs font-semibold shadow transition disabled:opacity-50"
>
{restoring ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
<span>Restore This Version</span>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{selectedSnapshot.contentHtml ? (
<div
className="prose prose-invert prose-sm max-w-none text-neutral-200"
dangerouslySetInnerHTML={{ __html: selectedSnapshot.contentHtml }}
/>
) : (
<pre className="text-xs text-neutral-300 whitespace-pre-wrap font-sans">
{selectedSnapshot.contentText || '(Empty snapshot)'}
</pre>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-neutral-500">
Select a version from the timeline on the left to preview it.
</div>
)}
</div>
</div>
</div>
</div>
);
}
+300
View File
@@ -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<NotebookShareItem[]>([]);
const [loading, setLoading] = useState(false);
const [sharing, setSharing] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [customEmail, setCustomEmail] = useState<string>('');
const [selectedRole, setSelectedRole] = useState<'write' | 'read'>('write');
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-150">
<div className="relative w-full max-w-lg bg-neutral-900 border border-neutral-700 rounded-2xl shadow-2xl overflow-hidden text-neutral-100">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-800 bg-neutral-900/80">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-400 border border-blue-500/20">
<Users className="w-5 h-5" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Share Notebook</h2>
<p className="text-xs text-neutral-400">
Collaborate on <span className="text-white font-medium">"{notebook.name}"</span>
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-400 hover:text-white rounded-lg hover:bg-neutral-800 transition"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-5">
{error && (
<div className="p-3 bg-red-950/60 border border-red-800/80 rounded-xl text-xs text-red-200">
{error}
</div>
)}
{/* Add collaborator form */}
<form onSubmit={handleAddShare} className="space-y-3">
<label className="block text-xs font-medium text-neutral-300">
Add Collaborator
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="sm:col-span-2 space-y-1.5">
{eligibleUsers.length > 0 ? (
<select
value={selectedUserId}
onChange={(e) => {
setSelectedUserId(e.target.value);
setCustomEmail('');
}}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white focus:outline-none focus:border-blue-500"
>
{eligibleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))}
</select>
) : (
<input
type="email"
value={customEmail}
onChange={(e) => 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"
/>
)}
</div>
<div>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value as 'write' | 'read')}
className="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-xl text-xs text-white focus:outline-none focus:border-blue-500"
>
<option value="write">Can edit</option>
<option value="read">Can view</option>
</select>
</div>
</div>
<button
type="submit"
disabled={sharing || (!selectedUserId && !customEmail.trim())}
className="w-full flex items-center justify-center gap-1.5 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl text-xs font-semibold shadow transition disabled:opacity-50"
>
{sharing ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Inviting…</span>
</>
) : (
<>
<UserPlus className="w-3.5 h-3.5" />
<span>Share Notebook</span>
</>
)}
</button>
</form>
{/* Current Collaborators */}
<div className="space-y-2 pt-2 border-t border-neutral-800">
<h3 className="text-xs font-semibold text-neutral-300">Who has access</h3>
{loading ? (
<div className="py-4 text-center text-xs text-neutral-500">Loading collaborators…</div>
) : shares.length === 0 ? (
<div className="py-4 text-center text-xs text-neutral-500 bg-neutral-800/30 rounded-xl border border-neutral-800">
This notebook is private to you.
</div>
) : (
<div className="space-y-2 max-h-52 overflow-y-auto pr-1">
{shares.map((s) => (
<div
key={s.id}
className="flex items-center justify-between p-2.5 bg-neutral-800/60 border border-neutral-700/60 rounded-xl text-xs"
>
<div className="flex items-center gap-2.5 truncate">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-[11px] font-bold shrink-0"
style={{ backgroundColor: s.user?.avatarColor || '#2563eb' }}
>
{s.user?.name ? s.user.name.charAt(0).toUpperCase() : 'U'}
</div>
<div className="truncate">
<div className="font-medium text-white truncate">{s.user?.name || s.userId}</div>
<div className="text-[10px] text-neutral-400 truncate">{s.user?.email || ''}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
value={s.role}
onChange={(e) => handleUpdateRole(s.userId, e.target.value as 'write' | 'read')}
className="px-2 py-1 bg-neutral-700 border border-neutral-600 rounded-lg text-[11px] text-white focus:outline-none"
>
<option value="write">Can edit</option>
<option value="read">Can view</option>
</select>
<button
type="button"
onClick={() => handleRemoveShare(s.id)}
className="p-1 text-neutral-400 hover:text-red-400 rounded-lg hover:bg-neutral-700 transition"
title="Remove collaborator"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-6 py-3 border-t border-neutral-800 bg-neutral-900/80">
<button
type="button"
onClick={onClose}
className="px-4 py-1.5 rounded-xl text-xs font-semibold text-neutral-300 hover:text-white hover:bg-neutral-800 transition"
>
Done
</button>
</div>
</div>
</div>
);
}
+5
View File
@@ -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);
+6
View File
@@ -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
+141
View File
@@ -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<string | null> {
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<string | null> {
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<string | null> {
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);
}
+19 -5
View File
@@ -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 },
+33
View File
@@ -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 {