Add Image OCR search, Note Version History & Shared Notebooks
This commit is contained in:
1 parent
247b63691a
commit
969b6ef083
21 files changed
+2142
-76
No files matched your search
+4
-3
@@ -31,8 +31,9 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# Everclone data & uploads
|
||||
data/
|
||||
attachments/
|
||||
uploads/
|
||||
/data/
|
||||
/attachments/
|
||||
/uploads/
|
||||
*.enex
|
||||
*.zip
|
||||
*.traineddata
|
||||
Generated
+437
-53
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -42,12 +42,27 @@ model Notebook {
|
||||
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")
|
||||
@@ -72,6 +87,7 @@ model Note {
|
||||
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")
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user