diff --git a/apps/web_v1/package-lock.json b/apps/web_v1/package-lock.json index 39e3d5e..f9f4feb 100644 --- a/apps/web_v1/package-lock.json +++ b/apps/web_v1/package-lock.json @@ -9,8 +9,20 @@ "version": "1.0.0", "dependencies": { "@arco-design/web-react": "^2.66.16", + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-markdown": "^6.5.1", + "@codemirror/state": "^6.7.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.6", + "@types/dompurify": "^3.0.5", + "dompurify": "^3.4.12", + "mammoth": "^1.12.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "read-excel-file": "^9.3.4", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/node": "^24.13.2", @@ -67,6 +79,148 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", + "integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -101,6 +255,79 @@ "tslib": "^2.4.0" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -770,6 +997,63 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -784,14 +1068,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.23", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -808,6 +1090,24 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -834,6 +1134,24 @@ } } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/b-tween": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/b-tween/-/b-tween-0.3.3.tgz", @@ -846,6 +1164,92 @@ "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==", "license": "MIT" }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -881,12 +1285,34 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/compute-scroll-into-view": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -899,6 +1325,45 @@ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -915,6 +1380,25 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -925,6 +1409,52 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -943,6 +1473,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", @@ -970,18 +1506,190 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1261,6 +1969,16 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1273,6 +1991,890 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1292,6 +2894,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/number-precision": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/number-precision/-/number-precision-1.6.0.tgz", @@ -1307,6 +2915,12 @@ "node": ">=0.10.0" } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/oxlint": { "version": "1.74.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.74.0.tgz", @@ -1356,6 +2970,46 @@ } } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1452,6 +3106,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1469,6 +3129,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1535,6 +3205,33 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -1551,6 +3248,115 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-excel-file": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.3.4.tgz", + "integrity": "sha512-/Vdzh+951TFXKQvz7Hfq4Rh1b7nb4lVZ8i5Bm/f279EqNiI2+WK124yV2/ryrXenA1fhGPfAgiY9K3J1ZMuZtA==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.3", + "saxen": "^11.0.2", + "unzipper-esm": "^0.13.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -1591,6 +3397,21 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/saxen": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/saxen/-/saxen-11.1.0.tgz", + "integrity": "sha512-GOxBOAmiWVAytOHBuMlgFMZ4MAk+2Ny5QJpzM9I4IozfPLXq3FsDdIHNviTQGZGIx7G2mBkA+uySiJlYmSU1Gg==", + "license": "MIT", + "engines": { + "node": ">= 20.12" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1609,6 +3430,12 @@ "compute-scroll-into-view": "^1.0.20" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -1634,6 +3461,69 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1651,6 +3541,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1671,6 +3581,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -1678,6 +3594,106 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unzipper-esm": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz", + "integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -1721,6 +3737,40 @@ } } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -1798,6 +3848,31 @@ "optional": true } } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/apps/web_v1/package.json b/apps/web_v1/package.json index 7f5e0af..30f9883 100644 --- a/apps/web_v1/package.json +++ b/apps/web_v1/package.json @@ -11,8 +11,20 @@ }, "dependencies": { "@arco-design/web-react": "^2.66.16", + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-markdown": "^6.5.1", + "@codemirror/state": "^6.7.1", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.6", + "@types/dompurify": "^3.0.5", + "dompurify": "^3.4.12", + "mammoth": "^1.12.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "read-excel-file": "^9.3.4", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/node": "^24.13.2", diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 2acdee3..88cd3c7 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -11,7 +11,8 @@ const requiredFiles = [ 'src/pages/projects/project-channel-page.tsx', 'src/pages/projects/project-tasks.tsx', 'src/pages/projects/project-ai.tsx', - 'src/pages/projects/project-notes.tsx', + 'src/pages/projects/project-documents.tsx', + 'src/pages/projects/document-markdown-editor.tsx', 'src/pages/projects/project-cron.tsx', 'src/pages/projects/project-new-channel.tsx', 'src/pages/projects/project-rail.tsx', @@ -25,6 +26,7 @@ const requiredFiles = [ 'src/api/search.ts', 'src/api/inbox.ts', 'src/api/ai.ts', + 'src/api/documents.ts', 'scripts/api-client.test.mjs', ] diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 513a066..6d22c84 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -85,7 +85,7 @@ const visualCheckWorkspace = { { id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'email', count: 2, url: '', sortOrder: 1 }, { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 0, url: '', sortOrder: 1 }, { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 会话', icon: 'robot', count: 0, url: '', sortOrder: 2 }, - { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 }, + { id: 'documents', projectId, type: 'documents', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 }, { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 }, ], tags: [ @@ -160,7 +160,7 @@ const visualCheckWorkspace = { }, ], aiSessions: [], - notesSources: [], + documents: [], cronPlans: [], } @@ -182,6 +182,10 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { await route.fulfill({ json: visualCheckWorkspace }) return } + if (url.pathname === `/api/v1/projects/${projectId}/documents` && route.request().method() === 'GET') { + await route.fulfill({ json: [] }) + return + } if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) { if (route.request().method() === 'POST') { const input = route.request().postDataJSON() @@ -271,7 +275,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { return } if (url.pathname.endsWith('/deposit') && route.request().method() === 'POST') { - await route.fulfill({ status: 201, json: { noteId: '019b0000-0000-7000-8000-000000000099', projectId } }) + await route.fulfill({ status: 201, json: { documentId: '019b0000-0000-7000-8000-000000000099', projectId } }) return } if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') { @@ -450,7 +454,7 @@ let aiSessionCheck = null for (const channel of [ { label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' }, { label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: '' }, - { label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' }, + { label: '笔记资料', pageClass: 'documents-canvas', expectedHeading: '' }, { label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' }, { label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' }, ]) { @@ -502,6 +506,10 @@ for (const channel of [ await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true }) } + if (channel.label === '笔记资料') { + await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true }) + } + channelPageChecks.push(pageCheck) } diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index cb972c6..5d60a12 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -1400,6 +1400,386 @@ min-width: 0; } +.documents-canvas { + position: relative; + height: calc(100vh - 90px); + min-height: 560px; + display: flex; + margin: -16px; + overflow: hidden; + background: var(--senlin-panel); + color: var(--senlin-text); +} + +.documents-tree-pane { + min-width: 210px; + max-width: 420px; + display: flex; + flex: 0 0 auto; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--senlin-border); + background: color-mix(in srgb, var(--senlin-panel) 94%, var(--senlin-bg)); +} + +.documents-tree-tools, +.documents-tabs-bar { + min-height: 42px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--senlin-border); +} + +.documents-tree-tools { + gap: 2px; + padding: 0 6px; +} + +.documents-tree-spacer { + flex: 1; +} + +.documents-tree { + min-height: 0; + flex: 1; + overflow: auto; + padding: 6px 0 12px; +} + +.document-tree-row { + width: calc(100% - 8px); + height: 30px; + display: flex; + align-items: center; + gap: 7px; + margin: 1px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--senlin-text); + font-size: 13px; + text-align: left; + cursor: default; +} + +.document-tree-row:hover { + background: color-mix(in srgb, var(--senlin-primary) 8%, transparent); +} + +.document-tree-row.active { + background: color-mix(in srgb, var(--senlin-primary) 14%, transparent); + color: var(--senlin-primary); +} + +.document-tree-row > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-tree-row .arco-icon { + flex: 0 0 auto; +} + +.tree-chevron { + width: 11px; + flex: 0 0 11px; + color: var(--senlin-muted); + font-size: 15px; + line-height: 1; +} + +.documents-resizer { + z-index: 2; + width: 4px; + flex: 0 0 4px; + margin-left: -4px; + cursor: col-resize; +} + +.documents-resizer:hover { + background: var(--senlin-primary); +} + +.documents-editor-pane { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; +} + +.documents-tabs-bar { + flex: 0 0 42px; + background: color-mix(in srgb, var(--senlin-panel) 97%, var(--senlin-bg)); +} + +.documents-tabs { + min-width: 0; + display: flex; + flex: 1; + align-self: stretch; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} + +.document-tab { + min-width: 132px; + max-width: 220px; + display: flex; + align-items: center; + gap: 7px; + border: 0; + border-right: 1px solid var(--senlin-border); + border-bottom: 2px solid transparent; + background: color-mix(in srgb, var(--senlin-bg) 50%, var(--senlin-panel)); + color: var(--senlin-muted); + padding: 0 10px; + cursor: pointer; +} + +.document-tab.active { + border-bottom-color: var(--senlin-primary); + background: var(--senlin-panel); + color: var(--senlin-text); +} + +.document-tab > span { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.document-tab > .arco-icon:last-child { + opacity: 0; +} + +.document-tab:hover > .arco-icon:last-child, +.document-tab.active > .arco-icon:last-child { + opacity: 1; +} + +.save-dot { + width: 6px; + height: 6px; + flex: 0 0 6px; + border-radius: 50%; + background: transparent; +} + +.save-dot.dirty { + background: #ff7d00; +} + +.save-dot.saving { + background: #165dff; + animation: document-pulse 1s infinite; +} + +.save-dot.saved { + background: #00b42a; +} + +.save-dot.error { + background: #f53f3f; +} + +@keyframes document-pulse { + 50% { opacity: 0.3; } +} + +.documents-tab-actions { + height: 100%; + display: flex; + flex: 0 0 auto; + align-items: center; + border-left: 1px solid var(--senlin-border); + padding: 0 5px; +} + +.document-editor-body { + min-height: 0; + flex: 1; + overflow: hidden; + background: var(--senlin-panel); +} + +.document-codemirror, +.document-codemirror .cm-editor { + height: 100%; +} + +.document-markdown-preview, +.document-office-preview, +.document-text-preview, +.document-sheet-preview { + width: min(900px, calc(100% - 48px)); + height: 100%; + box-sizing: border-box; + margin: 0 auto; + overflow: auto; + padding: 28px 34px 60px; +} + +.document-markdown-preview { + font-size: 15px; + line-height: 1.75; +} + +.document-markdown-preview img { + max-width: 100%; +} + +.document-markdown-preview pre, +.document-text-preview { + border-radius: 6px; + background: color-mix(in srgb, var(--senlin-bg) 78%, var(--senlin-panel)); + padding: 14px; + white-space: pre-wrap; +} + +.document-markdown-preview table, +.document-sheet-preview table { + border-collapse: collapse; +} + +.document-markdown-preview th, +.document-markdown-preview td, +.document-sheet-preview td { + border: 1px solid var(--senlin-border); + padding: 7px 10px; +} + +.document-pdf-preview { + width: 100%; + height: 100%; + border: 0; +} + +.document-media-preview { + width: 100%; + height: 100%; + display: grid; + place-items: center; + overflow: auto; + padding: 24px; + box-sizing: border-box; +} + +.document-media-preview img, +.document-media-preview video { + max-width: 100%; + max-height: 100%; +} + +.document-upload-summary { + display: grid; + gap: 6px; + border-top: 1px solid var(--senlin-border); + padding: 9px 10px 10px; + font-size: 12px; +} + +.document-upload-summary > div, +.document-upload-row { + min-width: 0; + display: flex; + justify-content: space-between; + gap: 8px; +} + +.document-upload-row span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.document-upload-row button { + border: 0; + background: transparent; + color: var(--senlin-primary); + padding: 0; + font-size: 12px; + cursor: pointer; +} + +.document-context-menu { + position: fixed; + z-index: 1000; + width: 150px; + display: grid; + border: 1px solid var(--senlin-border); + border-radius: 6px; + background: var(--senlin-panel); + box-shadow: 0 8px 24px rgb(0 0 0 / 16%); + padding: 5px; +} + +.document-context-menu button { + display: flex; + align-items: center; + gap: 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--senlin-text); + padding: 8px 10px; + text-align: left; +} + +.document-context-menu button:hover { + background: var(--senlin-soft-blue); +} + +.document-context-menu button.danger { + color: #f53f3f; +} + +.documents-mobile-switch { + display: none; +} + +@media (max-width: 720px) { + .documents-canvas { + min-height: 480px; + } + + .documents-canvas .documents-tree-pane, + .documents-canvas .documents-editor-pane { + width: 100% !important; + max-width: none; + } + + .documents-canvas.mobile-tree .documents-editor-pane, + .documents-canvas.mobile-editor .documents-tree-pane, + .documents-canvas .documents-resizer { + display: none; + } + + .documents-mobile-switch { + display: block; + border-bottom: 1px solid var(--senlin-border); + padding: 4px 8px; + } + + .document-markdown-preview, + .document-office-preview, + .document-text-preview, + .document-sheet-preview { + width: 100%; + padding: 20px 18px 48px; + } + + .documents-tab-actions .arco-btn { + padding: 0 7px; + } +} + .compact-card .arco-card-body { padding: 12px 14px; } diff --git a/apps/web_v1/src/api/dataset.ts b/apps/web_v1/src/api/dataset.ts index 89e4e0a..549463c 100644 --- a/apps/web_v1/src/api/dataset.ts +++ b/apps/web_v1/src/api/dataset.ts @@ -46,7 +46,7 @@ export type DatasetItemPageDTO = { } export type DatasetDepositDTO = { - noteId: string + documentId: string projectId: string } diff --git a/apps/web_v1/src/api/documents.ts b/apps/web_v1/src/api/documents.ts new file mode 100644 index 0000000..4d88a48 --- /dev/null +++ b/apps/web_v1/src/api/documents.ts @@ -0,0 +1,189 @@ +import { ApiError, apiRequest, getApiBaseUrl, type ApiSession } from './client' + +export type DocumentDTO = { + id: string + projectId: string + parentId: string | null + kind: 'folder' | 'file' + name: string + extension: string + mimeType: string + size: number + revision: number + markdown?: string + hasBlob: boolean + sourceInboxItemId: string | null + createdAt: string + updatedAt: string +} + +export type DocumentOpenIntent = { + intentId: string + mode: 'create' | 'open' + projectId: string + documentId?: string + suggestedName?: string + initialMarkdown?: string + targetDirectoryId?: string + sourceType?: string + sourceId?: string +} + +export type DocumentShareDTO = { + id: string + url?: string + expiresAt: string + revokedAt: string | null +} + +export function listDocuments(session: ApiSession, projectId: string, signal?: AbortSignal) { + return apiRequest(`/api/v1/projects/${projectId}/documents`, { token: session.token, signal }) +} + +export function getDocument(session: ApiSession, projectId: string, documentId: string, signal?: AbortSignal) { + return apiRequest(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token, signal }) +} + +export function createDocumentFolder(session: ApiSession, projectId: string, name: string, parentId = '') { + return apiRequest(`/api/v1/projects/${projectId}/documents/folders`, { + method: 'POST', + token: session.token, + body: { name, parentId }, + }) +} + +export function createMarkdownDocument( + session: ApiSession, + projectId: string, + input: { name: string; parentId?: string; markdown?: string; sourceInboxItemId?: string }, +) { + return apiRequest(`/api/v1/projects/${projectId}/documents/markdown`, { + method: 'POST', + token: session.token, + body: input, + }) +} + +export function updateDocument( + session: ApiSession, + projectId: string, + documentId: string, + input: { name?: string; parentId?: string; markdown?: string; revision?: number }, +) { + return apiRequest(`/api/v1/projects/${projectId}/documents/${documentId}`, { + method: 'PATCH', + token: session.token, + body: input, + }) +} + +export function deleteDocument(session: ApiSession, projectId: string, documentId: string) { + return apiRequest(`/api/v1/projects/${projectId}/documents/${documentId}`, { + method: 'DELETE', + token: session.token, + responseType: 'void', + }) +} + +export function createDocumentShare(session: ApiSession, projectId: string, documentId: string, expiresInDays = 7) { + return apiRequest(`/api/v1/projects/${projectId}/documents/${documentId}/shares`, { + method: 'POST', + token: session.token, + body: { expiresInDays }, + }) +} + +export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string, download = false) { + const suffix = download ? 'download' : 'content' + const response = await fetch(`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/${suffix}`, { + headers: { Authorization: `Bearer ${session.token}` }, + }) + if (!response.ok) throw await responseError(response) + return response.blob() +} + +export function fetchLegacySheetPreview(session: ApiSession, projectId: string, documentId: string) { + return apiRequest<{ sheetName: string; rows: string[][]; truncated: boolean }>( + `/api/v1/projects/${projectId}/documents/${documentId}/sheet-preview`, + { token: session.token }, + ) +} + +export async function exportDocument(session: ApiSession, projectId: string, documentId: string, format: 'md' | 'pdf' | 'docx') { + const response = await fetch( + `${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/export?format=${format}`, + { headers: { Authorization: `Bearer ${session.token}` } }, + ) + if (!response.ok) throw await responseError(response) + const blob = await response.blob() + const disposition = response.headers.get('content-disposition') ?? '' + downloadBlob(blob, dispositionName(disposition) || `document.${format}`) +} + +export type UploadControl = { + promise: Promise + cancel: () => void +} + +export function uploadDocument( + session: ApiSession, + projectId: string, + file: File, + parentId: string, + onProgress: (progress: number) => void, +): UploadControl { + const xhr = new XMLHttpRequest() + const promise = new Promise((resolve, reject) => { + xhr.open('POST', `${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/uploads`) + xhr.setRequestHeader('Authorization', `Bearer ${session.token}`) + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100)) + } + xhr.onerror = () => reject(new ApiError(0, 'network_error', '上传失败,请检查网络后重试')) + xhr.onabort = () => reject(new ApiError(0, 'upload_cancelled', '上传已取消')) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText) as DocumentDTO) + } catch { + reject(new ApiError(xhr.status, 'invalid_response', '服务器返回了无效的上传结果')) + } + return + } + try { + const payload = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } } + reject(new ApiError(xhr.status, payload.error?.code ?? 'upload_failed', payload.error?.message ?? '上传失败')) + } catch { + reject(new ApiError(xhr.status, 'upload_failed', '上传失败')) + } + } + const body = new FormData() + body.append('file', file) + if (parentId) body.append('parentId', parentId) + xhr.send(body) + }) + return { promise, cancel: () => xhr.abort() } +} + +export function downloadBlob(blob: Blob, name: string) { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = name + anchor.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +async function responseError(response: Response) { + try { + const payload = await response.json() as { error?: { code?: string; message?: string } } + return new ApiError(response.status, payload.error?.code ?? 'request_failed', payload.error?.message ?? '请求失败') + } catch { + return new ApiError(response.status, 'request_failed', '请求失败') + } +} + +function dispositionName(value: string) { + const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + return encoded ? decodeURIComponent(encoded) : '' +} diff --git a/apps/web_v1/src/api/inbox.ts b/apps/web_v1/src/api/inbox.ts index 7ef3647..94ff3b3 100644 --- a/apps/web_v1/src/api/inbox.ts +++ b/apps/web_v1/src/api/inbox.ts @@ -13,7 +13,7 @@ export type InboxItemDTO = { export type InboxSuggestionDTO = { id: string - kind: 'task' | 'note' | 'source' + kind: 'task' | 'document' title: string body: string } diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx index 1ca5b88..0deca0a 100644 --- a/apps/web_v1/src/api/mappers.tsx +++ b/apps/web_v1/src/api/mappers.tsx @@ -5,10 +5,10 @@ import type { WorkspaceChannelDTO, WorkspaceCronPlanDTO, WorkspaceInboxDTO, - WorkspaceNoteSourceDTO, + WorkspaceDocumentDTO, WorkspaceTaskDTO, } from './projects' -import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types' +import type { AISession, ChannelKey, CronJob, DocumentSummary, InboxItem, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types' const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F'] @@ -34,7 +34,7 @@ export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWo inbox: payload.inbox.map(mapInbox), tasks: payload.tasks.map(mapTask), aiSessions: payload.aiSessions.map(mapAISession), - notes: payload.notesSources.map(mapNoteSource), + documents: payload.documents.map(mapDocument), cronJobs: payload.cronPlans.map(mapCronPlan), } } @@ -75,8 +75,8 @@ function mapChannelKey(type: string, id = ''): ChannelKey { return 'tasks' case 'ai_sessions': return 'ai' - case 'notes_sources': - return 'notes' + case 'documents': + return 'documents' case 'cron': return 'cron' case 'overview': @@ -96,7 +96,7 @@ function channelLabel(channel: WorkspaceChannelDTO) { return '工作计划' case 'ai_sessions': return 'AI 会话' - case 'notes_sources': + case 'documents': return '笔记资料' case 'cron': return '计划任务' @@ -115,7 +115,7 @@ function channelIcon(channel: WorkspaceChannelDTO) { return case 'ai_sessions': return - case 'notes_sources': + case 'documents': return case 'cron': return @@ -194,14 +194,14 @@ function mapAISession(session: WorkspaceAISessionDTO): AISession { } } -function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource { +function mapDocument(document: WorkspaceDocumentDTO): DocumentSummary { return { - id: note.id, - title: note.title, - type: note.kind === 'note' ? '笔记' : note.source, - updated: formatBackendDate(note.updatedAt), - owner: note.source, - source: note.source, + id: document.id, + name: document.name, + kind: document.kind, + extension: document.extension, + mimeType: document.mimeType, + updated: formatBackendDate(document.updatedAt), } } diff --git a/apps/web_v1/src/api/projects.ts b/apps/web_v1/src/api/projects.ts index c77fa83..db41549 100644 --- a/apps/web_v1/src/api/projects.ts +++ b/apps/web_v1/src/api/projects.ts @@ -70,14 +70,14 @@ export type WorkspaceAISessionDTO = { references: string[] } -export type WorkspaceNoteSourceDTO = { +export type WorkspaceDocumentDTO = { id: string projectId: string kind: string - title: string + name: string + extension: string + mimeType: string updatedAt: string - tag: string - source: string } export type WorkspaceCronPlanDTO = { @@ -99,7 +99,7 @@ export type ProjectWorkspaceDTO = { inbox: WorkspaceInboxDTO[] tasks: WorkspaceTaskDTO[] aiSessions: WorkspaceAISessionDTO[] - notesSources: WorkspaceNoteSourceDTO[] + documents: WorkspaceDocumentDTO[] cronPlans: WorkspaceCronPlanDTO[] } @@ -119,16 +119,6 @@ export type TaskDTO = { updatedAt: string } -export type SourceDTO = { - id: string - projectId: string - kind: string - title: string - filePath: string - createdAt: string - updatedAt: string -} - export type CronPlanDTO = { id: string projectId: string @@ -168,11 +158,6 @@ export type UpdateTaskInput = { tag?: string } -export type UploadSourceInput = { - title?: string - file: File -} - export type CreateCronPlanInput = { title: string schedule: string @@ -228,17 +213,6 @@ export async function updateTask(session: ApiSession, projectId: string, taskId: }) } -export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) { - const body = new FormData() - body.append('file', input.file) - if (input.title) body.append('title', input.title) - return apiRequest(`/api/v1/projects/${projectId}/sources`, { - method: 'POST', - token: session.token, - body, - }) -} - export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) { return apiRequest(`/api/v1/projects/${projectId}/cron-plans`, { method: 'POST', diff --git a/apps/web_v1/src/api/search.ts b/apps/web_v1/src/api/search.ts index a6c8c52..a824fad 100644 --- a/apps/web_v1/src/api/search.ts +++ b/apps/web_v1/src/api/search.ts @@ -1,7 +1,7 @@ import { apiRequest, type ApiSession } from './client' export type SearchResultDTO = { - type: 'project' | 'task' | 'note' + type: 'project' | 'task' | 'document' id: string projectId: string title: string diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 681d48c..35226c9 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -27,11 +27,11 @@ import { fetchProjects, updateProject, updateTask, - uploadSource, } from '../api/projects' +import type { DocumentOpenIntent } from '../api/documents' import type { SearchResultDTO } from '../api/search' import { LoginPage } from '../pages/login' -import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals' +import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type TaskDraft } from '../pages/projects/project-action-modals' import { SearchResultPreview } from '../pages/projects/search-result-preview' import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar' import { ProjectPage } from '../pages/workspace-home' @@ -54,6 +54,7 @@ function App() { const [actionLoading, setActionLoading] = useState(false) const [activeModal, setActiveModal] = useState(null) const [searchResultPreview, setSearchResultPreview] = useState(null) + const [documentIntent, setDocumentIntent] = useState(null) const workspaceSearch = useWorkbenchSearch(session) const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => { @@ -119,6 +120,17 @@ function App() { return { ...result, refreshFailed: true } } } + setDocumentIntent({ + intentId: crypto.randomUUID(), + mode: 'open', + projectId, + documentId: result.documentId, + sourceType: 'dataset', + sourceId: itemId, + }) + selectActiveProject(projectId) + setActiveView('project') + setActiveChannel('documents') return result }, [session, workspaces]) @@ -238,22 +250,6 @@ function App() { }, '任务已创建') } - function handleUploadSource(draft: SourceDraft) { - if (!draft.file) { - Message.warning('请选择文件') - return - } - const file = draft.file - void runAction(async () => { - await uploadSource(requireSession(), requireActiveProject(), { - title: draft.title.trim(), - file, - }) - await refreshAfterAction() - setActiveChannel('notes') - }, '文件已上传') - } - function handleCreateCronPlan(draft: CronDraft) { if (!draft.title.trim()) { Message.warning('请输入计划名称') @@ -375,7 +371,16 @@ function App() { setActiveView('project') setActiveChannel(target.channel) setActiveTaskID(target.openTask ? result.id : null) - if (result.type === 'note') setSelectedItem(result.title) + if (result.type === 'document') { + setDocumentIntent({ + intentId: crypto.randomUUID(), + mode: 'open', + projectId: result.projectId, + documentId: result.id, + sourceType: 'search', + sourceId: result.id, + }) + } } return ( @@ -389,6 +394,11 @@ function App() { { + setDocumentIntent((current) => current?.intentId === intentId ? null : current) + }} workspaces={workspaces} activeChannel={activeChannel} activeTaskID={activeTaskID} @@ -411,7 +421,6 @@ function App() { onToggleTheme={() => setTheme(dark ? 'light' : 'dark')} onCreateProject={() => setActiveModal('project')} onCreateTask={() => setActiveModal('task')} - onUploadSource={() => setActiveModal('source')} onCreateCronPlan={() => setActiveModal('cron')} onUpdateWorkspaceTask={handleUpdateWorkspaceTask} onUpdateProject={handleUpdateProject} @@ -446,7 +455,6 @@ function App() { onClose={() => setActiveModal(null)} onCreateProject={handleCreateProject} onCreateTask={handleCreateTask} - onUploadSource={handleUploadSource} onCreateCronPlan={handleCreateCronPlan} tagOptions={activeTagOptions} /> @@ -459,13 +467,13 @@ function App() { function searchResultTarget(type: string): { channel: ChannelKey; openTask: boolean } | null { if (type === 'project') return { channel: 'overview', openTask: false } if (type === 'task') return { channel: 'tasks', openTask: true } - if (type === 'note') return { channel: 'notes', openTask: false } + if (type === 'document') return { channel: 'documents', openTask: false } return null } function isAuthorizedExternalPreview(result: SearchResultDTO) { return ( - (result.type === 'task' || result.type === 'note') + (result.type === 'task' || result.type === 'document') && typeof result.projectId === 'string' && result.projectId.trim() !== '' && typeof result.title === 'string' diff --git a/apps/web_v1/src/pages/projects/document-markdown-editor.tsx b/apps/web_v1/src/pages/projects/document-markdown-editor.tsx new file mode 100644 index 0000000..43f5c44 --- /dev/null +++ b/apps/web_v1/src/pages/projects/document-markdown-editor.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react' +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' +import { markdown } from '@codemirror/lang-markdown' +import { EditorState } from '@codemirror/state' +import { EditorView, keymap, lineNumbers } from '@codemirror/view' +import { oneDark } from '@codemirror/theme-one-dark' + +export function DocumentMarkdownEditor({ + value, + dark, + onChange, + onSave, +}: { + value: string + dark: boolean + onChange: (value: string) => void + onSave: () => void +}) { + const hostRef = useRef(null) + const viewRef = useRef(null) + const onChangeRef = useRef(onChange) + const onSaveRef = useRef(onSave) + onChangeRef.current = onChange + onSaveRef.current = onSave + + useEffect(() => { + if (!hostRef.current) return + const view = new EditorView({ + parent: hostRef.current, + state: EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + history(), + markdown(), + keymap.of([ + { + key: 'Mod-s', + preventDefault: true, + run: () => { + onSaveRef.current() + return true + }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), + EditorView.lineWrapping, + EditorView.updateListener.of((update) => { + if (update.docChanged) onChangeRef.current(update.state.doc.toString()) + }), + EditorView.theme({ + '&': { height: '100%', fontSize: '14px' }, + '.cm-scroller': { fontFamily: 'JetBrains Mono, Cascadia Code, Consolas, monospace', lineHeight: '1.7' }, + '.cm-content': { padding: '22px 26px 40px' }, + '.cm-gutters': { borderRight: '1px solid var(--senlin-border)' }, + }), + ...(dark ? [oneDark] : []), + ], + }), + }) + viewRef.current = view + return () => { + view.destroy() + viewRef.current = null + } + // Recreate only when theme changes; external value updates are handled below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dark]) + + useEffect(() => { + const view = viewRef.current + if (!view || view.state.doc.toString() === value) return + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }) + }, [value]) + + return
+} diff --git a/apps/web_v1/src/pages/projects/project-action-modals.tsx b/apps/web_v1/src/pages/projects/project-action-modals.tsx index c84702f..d26f781 100644 --- a/apps/web_v1/src/pages/projects/project-action-modals.tsx +++ b/apps/web_v1/src/pages/projects/project-action-modals.tsx @@ -4,7 +4,7 @@ import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/we const { Text } = Typography const { TextArea } = Input -export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null +export type ProjectActionModal = 'project' | 'task' | 'cron' | null export type ProjectDraft = { name: string @@ -20,11 +20,6 @@ export type TaskDraft = { tag: string } -export type SourceDraft = { - title: string - file: File | null -} - export type CronDraft = { title: string schedule: string @@ -38,7 +33,6 @@ export function ProjectActionModals({ onClose, onCreateProject, onCreateTask, - onUploadSource, onCreateCronPlan, tagOptions, }: { @@ -47,20 +41,17 @@ export function ProjectActionModals({ onClose: () => void onCreateProject: (draft: ProjectDraft) => void onCreateTask: (draft: TaskDraft) => void - onUploadSource: (draft: SourceDraft) => void onCreateCronPlan: (draft: CronDraft) => void tagOptions: string[] }) { const [project, setProject] = useState({ name: '', identifier: '', icon: '', background: '', description: '' }) const [task, setTask] = useState({ title: '', description: '', tag: '' }) - const [source, setSource] = useState({ title: '', file: null }) const [cron, setCron] = useState({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) useEffect(() => { if (activeModal === null) { setProject({ name: '', identifier: '', icon: '', background: '', description: '' }) setTask({ title: '', description: '', tag: '' }) - setSource({ title: '', file: null }) setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' }) } }, [activeModal]) @@ -132,30 +123,6 @@ export function ProjectActionModals({ - onUploadSource(source)} - > - - - - - - void activeTaskID: string | null onSelectItem: (title: string) => void onOpenTask: (taskID: string) => void onCloseTask: () => void onCreateTask: () => void - onUploadSource: () => void onCreateCronPlan: () => void onCreateProjectTag: (name: string) => void onUpdateTask: (update: ProjectTaskUpdate) => void @@ -52,8 +60,17 @@ export function ProjectChannelPage({ return case 'ai': return - case 'notes': - return + case 'documents': + return ( + + ) case 'cron': return case 'new-channel': diff --git a/apps/web_v1/src/pages/projects/project-documents.tsx b/apps/web_v1/src/pages/projects/project-documents.tsx new file mode 100644 index 0000000..ba9919d --- /dev/null +++ b/apps/web_v1/src/pages/projects/project-documents.tsx @@ -0,0 +1,821 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Button, + Dropdown, + Empty, + Menu, + Message, + Progress, + Spin, + Tooltip, +} from '@arco-design/web-react' +import { + IconClose, + IconDelete, + IconDownload, + IconEdit, + IconEye, + IconFile, + IconFileAudio, + IconFileImage, + IconFilePdf, + IconFileVideo, + IconFolder, + IconFolderAdd, + IconLeft, + IconPlus, + IconRight, + IconSave, + IconShareAlt, + IconUpload, +} from '@arco-design/web-react/icon' +import ReactMarkdown from 'react-markdown' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' +import DOMPurify from 'dompurify' +import mammoth from 'mammoth' +import { readSheet as readXlsxSheet } from 'read-excel-file/browser' +import type { ApiSession } from '../../api/client' +import { + createDocumentFolder, + createDocumentShare, + createMarkdownDocument, + deleteDocument, + downloadBlob, + exportDocument, + fetchDocumentBlob, + fetchLegacySheetPreview, + getDocument, + listDocuments, + updateDocument, + uploadDocument, + type DocumentDTO, + type DocumentOpenIntent, + type UploadControl, +} from '../../api/documents' +import type { Theme } from './project-types' +import { DocumentMarkdownEditor } from './document-markdown-editor' + +type SaveStatus = 'clean' | 'dirty' | 'saving' | 'saved' | 'error' + +type DocumentTab = { + key: string + documentId?: string + parentId?: string + name: string + extension: string + mimeType: string + markdown: string + revision: number + status: SaveStatus + mode: 'edit' | 'preview' +} + +type UploadItem = { + id: string + file: File + progress: number + status: 'queued' | 'uploading' | 'done' | 'error' | 'cancelled' + error?: string +} + +export function ProjectDocuments({ + session, + projectId, + theme, + intent, + onIntentConsumed, +}: { + session: ApiSession + projectId: string + theme: Theme + intent: DocumentOpenIntent | null + onIntentConsumed: (intentId: string) => void +}) { + const [documents, setDocuments] = useState([]) + const [tabs, setTabs] = useState([]) + const [activeKey, setActiveKey] = useState('') + const [expanded, setExpanded] = useState>(new Set()) + const [loading, setLoading] = useState(true) + const [treeWidth, setTreeWidth] = useState(260) + const [treeCollapsed, setTreeCollapsed] = useState(false) + const [mobilePane, setMobilePane] = useState<'tree' | 'editor'>('tree') + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; document: DocumentDTO } | null>(null) + const [uploads, setUploads] = useState([]) + const tabsRef = useRef([]) + const timersRef = useRef(new Map()) + const uploadControlsRef = useRef(new Map()) + const fileInputRef = useRef(null) + tabsRef.current = tabs + + const activeTab = tabs.find((tab) => tab.key === activeKey) + const rootDocuments = useMemo(() => buildTree(documents), [documents]) + + const refreshTree = useCallback(async () => { + const result = await listDocuments(session, projectId) + setDocuments(result) + return result + }, [projectId, session]) + + const openDocument = useCallback(async (document: DocumentDTO) => { + if (document.kind === 'folder') { + setExpanded((current) => toggleSet(current, document.id)) + return + } + const existing = tabsRef.current.find((tab) => tab.documentId === document.id) + if (existing) { + setActiveKey(existing.key) + setMobilePane('editor') + return + } + try { + const detail = await getDocument(session, projectId, document.id) + const next = tabFromDocument(detail) + setTabs((current) => [...current, next]) + setActiveKey(next.key) + setMobilePane('editor') + } catch (error) { + Message.error(error instanceof Error ? error.message : '文档打开失败') + } + }, [projectId, session]) + + const createDraft = useCallback((name = '新建文件1.md', markdown = '', parentId = '') => { + const key = `draft:${crypto.randomUUID()}` + const tab: DocumentTab = { + key, + parentId, + name, + extension: '.md', + mimeType: 'text/markdown; charset=utf-8', + markdown, + revision: 0, + status: markdown ? 'dirty' : 'clean', + mode: 'edit', + } + setTabs((current) => [...current, tab]) + setActiveKey(key) + setMobilePane('editor') + return key + }, []) + + useEffect(() => { + let cancelled = false + const timers = timersRef.current + setTabs([]) + setActiveKey('') + setLoading(true) + void refreshTree().then(async (items) => { + if (cancelled) return + const stored = readStoredTabs(projectId) + const restored: DocumentTab[] = [] + for (const id of stored) { + const item = items.find((document) => document.id === id && document.kind === 'file') + if (!item) continue + try { + const detail = await getDocument(session, projectId, id) + if (!cancelled) restored.push(tabFromDocument(detail)) + } catch { + // Missing tabs are discarded. + } + } + if (cancelled) return + if (restored.length > 0) { + setTabs(restored) + setActiveKey(restored[0].key) + setMobilePane('editor') + } else { + createDraft() + } + }).catch((error) => { + if (!cancelled) Message.error(error instanceof Error ? error.message : '文档树加载失败') + }).finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + for (const timer of timers.values()) window.clearTimeout(timer) + timers.clear() + } + }, [createDraft, projectId, refreshTree, session]) + + useEffect(() => { + const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : []) + localStorage.setItem(storageKey(projectId), JSON.stringify(ids)) + }, [projectId, tabs]) + + useEffect(() => { + if (!intent || intent.projectId !== projectId) return + if (intent.mode === 'open' && intent.documentId) { + const document = documents.find((item) => item.id === intent.documentId) + if (document) void openDocument(document) + } else { + createDraft(intent.suggestedName || '新建文件1.md', intent.initialMarkdown || '', intent.targetDirectoryId || '') + } + onIntentConsumed(intent.intentId) + }, [createDraft, documents, intent, onIntentConsumed, openDocument, projectId]) + + const persistTab = useCallback(async (key: string, forcedMarkdown?: string) => { + const tab = tabsRef.current.find((item) => item.key === key) + if (!tab || tab.extension !== '.md' || tab.status === 'saving') return + const markdown = forcedMarkdown ?? tab.markdown + setTabs((current) => patchTab(current, key, { status: 'saving' })) + try { + if (!tab.documentId) { + const created = await createMarkdownDocument(session, projectId, { name: tab.name, parentId: tab.parentId, markdown }) + setDocuments((current) => [...current, created]) + setTabs((current) => patchTab(current, key, { + documentId: created.id, + key: created.id, + name: created.name, + revision: created.revision, + status: 'saved', + })) + setActiveKey((current) => current === key ? created.id : current) + } else { + const updated = await updateDocument(session, projectId, tab.documentId, { + markdown, + revision: tab.revision, + }) + setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) + setTabs((current) => patchTab(current, key, { + markdown: updated.markdown ?? markdown, + revision: updated.revision, + status: 'saved', + })) + } + } catch (error) { + setTabs((current) => patchTab(current, key, { status: 'error' })) + Message.error(error instanceof Error ? error.message : '自动保存失败,本地草稿已保留') + } + }, [projectId, session]) + + function changeMarkdown(key: string, markdown: string) { + setTabs((current) => patchTab(current, key, { markdown, status: 'dirty' })) + const previous = timersRef.current.get(key) + if (previous) window.clearTimeout(previous) + timersRef.current.set(key, window.setTimeout(() => { + timersRef.current.delete(key) + void persistTab(key, markdown) + }, 800)) + } + + async function addFolder(parentId = '') { + const name = window.prompt('目录名称') + if (!name?.trim()) return + try { + const created = await createDocumentFolder(session, projectId, name.trim(), parentId) + setDocuments((current) => [...current, created]) + } catch (error) { + Message.error(error instanceof Error ? error.message : '目录创建失败') + } + } + + async function renameNode(document: DocumentDTO) { + const name = window.prompt('重命名', document.name) + if (!name?.trim() || name.trim() === document.name) return + try { + const updated = await updateDocument(session, projectId, document.id, { name: name.trim() }) + setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) + setTabs((current) => current.map((tab) => tab.documentId === updated.id ? { ...tab, name: updated.name } : tab)) + } catch (error) { + Message.error(error instanceof Error ? error.message : '重命名失败') + } + } + + async function removeNode(document: DocumentDTO) { + if (!window.confirm(`确定删除“${document.name}”${document.kind === 'folder' ? '及其全部内容' : ''}吗?`)) return + try { + await deleteDocument(session, projectId, document.id) + const removed = descendantIds(documents, document.id) + setDocuments((current) => current.filter((item) => !removed.has(item.id))) + setTabs((current) => current.filter((tab) => !tab.documentId || !removed.has(tab.documentId))) + if (activeTab?.documentId && removed.has(activeTab.documentId)) setActiveKey('') + } catch (error) { + Message.error(error instanceof Error ? error.message : '删除失败') + } + } + + async function moveNode(documentId: string, parentId: string) { + const document = documents.find((item) => item.id === documentId) + if (!document || document.parentId === (parentId || null)) return + try { + const updated = await updateDocument(session, projectId, documentId, { parentId }) + setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item)) + } catch (error) { + Message.error(error instanceof Error ? error.message : '移动失败') + } + } + + function enqueueFiles(files: FileList | File[]) { + const accepted = Array.from(files).filter((file) => { + if (file.size <= 50 * 1024 * 1024) return true + Message.error(`${file.name} 超过 50 MB,未加入上传队列`) + return false + }) + setUploads((current) => [ + ...current, + ...accepted.map((file) => ({ + id: crypto.randomUUID(), + file, + progress: 0, + status: 'queued' as const, + })), + ]) + } + + useEffect(() => { + const running = uploads.filter((item) => item.status === 'uploading').length + const queued = uploads.filter((item) => item.status === 'queued').slice(0, Math.max(0, 3 - running)) + for (const item of queued) { + setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'uploading' } : candidate)) + const control = uploadDocument(session, projectId, item.file, '', (progress) => { + setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress } : candidate)) + }) + uploadControlsRef.current.set(item.id, control) + void control.promise.then((document) => { + setDocuments((current) => [...current, document]) + setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress: 100, status: 'done' } : candidate)) + void openDocument(document) + }).catch((error) => { + setUploads((current) => current.map((candidate) => candidate.id === item.id + ? { ...candidate, status: 'error', error: error instanceof Error ? error.message : '上传失败' } + : candidate)) + }).finally(() => { + uploadControlsRef.current.delete(item.id) + window.setTimeout(() => { + setUploads((current) => current.filter((candidate) => candidate.id !== item.id || candidate.status !== 'done')) + }, 1800) + }) + } + }, [openDocument, projectId, session, uploads]) + + function closeTab(tab: DocumentTab) { + if ((tab.status === 'dirty' || tab.status === 'error') && !window.confirm(`“${tab.name}”尚未成功保存,仍要关闭吗?`)) return + const index = tabs.findIndex((item) => item.key === tab.key) + const next = tabs.filter((item) => item.key !== tab.key) + if (tab.key !== activeKey) { + setTabs(next) + return + } + const replacement = next[Math.max(0, index - 1)] + if (replacement) { + setTabs(next) + setActiveKey(replacement.key) + return + } + const key = `draft:${crypto.randomUUID()}` + setTabs([{ + key, + name: '新建文件1.md', + extension: '.md', + mimeType: 'text/markdown; charset=utf-8', + markdown: '', + revision: 0, + status: 'clean', + mode: 'edit', + }]) + setActiveKey(key) + } + + async function downloadActive() { + if (!activeTab?.documentId) return + try { + const blob = await fetchDocumentBlob(session, projectId, activeTab.documentId, true) + downloadBlob(blob, activeTab.name) + } catch (error) { + Message.error(error instanceof Error ? error.message : '下载失败') + } + } + + async function shareActive(days: 1 | 7 | 30) { + if (!activeTab?.documentId) { + Message.warning('请先保存文档') + return + } + try { + const share = await createDocumentShare(session, projectId, activeTab.documentId, days) + if (share.url) { + try { + await navigator.clipboard.writeText(share.url) + Message.success(`${days} 天有效的预览链接已复制`) + } catch { + window.prompt('复制分享链接', share.url) + } + } + } catch (error) { + Message.error(error instanceof Error ? error.message : '分享链接创建失败') + } + } + + function beginResize(event: React.MouseEvent) { + const startX = event.clientX + const startWidth = treeWidth + function move(moveEvent: MouseEvent) { + setTreeWidth(Math.min(420, Math.max(210, startWidth + moveEvent.clientX - startX))) + } + function finish() { + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', finish) + } + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', finish) + } + + const uploadProgress = uploads.length === 0 + ? 0 + : Math.round(uploads.reduce((sum, item) => sum + item.progress, 0) / uploads.length) + + return ( +
setContextMenu(null)} + onDragOver={(event) => event.preventDefault()} + onDrop={(event) => { + event.preventDefault() + if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files) + }} + > + {!treeCollapsed && ( + + )} + {!treeCollapsed &&
} + +
+
+ {treeCollapsed && + ))} +
+ {activeTab && ( +
+ {activeTab.extension === '.md' && ( + +
+ )} +
+ +
+ +
+ +
+ {!activeTab ? : activeTab.extension === '.md' && activeTab.mode === 'edit' ? ( + changeMarkdown(activeTab.key, value)} + onSave={() => void persistTab(activeTab.key)} + /> + ) : activeTab.extension === '.md' ? ( + + ) : activeTab.documentId ? ( + + ) : null} +
+ + + {contextMenu && ( +
+ {contextMenu.document.kind === 'folder' && ( + <> + + + + )} + + +
+ )} +
+ ) +} + +type TreeEntry = { document: DocumentDTO; children: TreeEntry[] } + +function TreeNode({ + node, + depth, + expanded, + activeDocumentId, + onToggle, + onOpen, + onMove, + onContextMenu, +}: { + node: TreeEntry + depth: number + expanded: Set + activeDocumentId?: string + onToggle: (id: string) => void + onOpen: (document: DocumentDTO) => void + onMove: (documentId: string, parentId: string) => void + onContextMenu: (event: React.MouseEvent, document: DocumentDTO) => void +}) { + const { document } = node + const open = expanded.has(document.id) + return ( + <> + + {document.kind === 'folder' && open && node.children.map((child) => ( + + ))} + + ) +} + +function MarkdownPreview({ markdown }: { markdown: string }) { + return ( +
+ {markdown} +
+ ) +} + +function SmartPreview({ session, projectId, tab }: { session: ApiSession; projectId: string; tab: DocumentTab }) { + const [url, setUrl] = useState('') + const [text, setText] = useState('') + const [html, setHtml] = useState('') + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + let objectURL = '' + setLoading(true) + setError('') + const extension = tab.extension.toLowerCase() + if (extension === '.xls') { + void fetchLegacySheetPreview(session, projectId, tab.documentId!).then((preview) => { + if (active) setRows(preview.rows) + }).catch((reason) => { + if (active) setError(reason instanceof Error ? reason.message : '预览加载失败') + }).finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + } + void fetchDocumentBlob(session, projectId, tab.documentId!).then(async (blob) => { + if (!active) return + if (isText(extension, tab.mimeType)) { + setText(await blob.text()) + } else if (extension === '.docx') { + const result = await mammoth.convertToHtml({ arrayBuffer: await blob.arrayBuffer() }) + if (active) setHtml(DOMPurify.sanitize(result.value)) + } else if (extension === '.xlsx') { + const parsed = await readXlsxSheet(blob) + if (active) setRows(parsed as unknown[][]) + } else { + const previewBlob = extension === '.svg' + ? new Blob([DOMPurify.sanitize(await blob.text(), { USE_PROFILES: { svg: true, svgFilters: true } })], { type: 'image/svg+xml' }) + : blob + objectURL = URL.createObjectURL(previewBlob) + setUrl(objectURL) + } + }).catch((reason) => { + if (active) setError(reason instanceof Error ? reason.message : '预览加载失败') + }).finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + if (objectURL) URL.revokeObjectURL(objectURL) + } + }, [projectId, session, tab.documentId, tab.extension, tab.mimeType]) + + if (loading) return + if (error) return + if (tab.mimeType.startsWith('image/')) return
{tab.name}
+ if (tab.mimeType.startsWith('audio/')) return
+ if (tab.mimeType.startsWith('video/')) return
+ if (tab.extension === '.pdf') return ` + default: + return `

此文件暂不支持在线预览。

` + } +} + +func (h *Handler) sharedContent(c *gin.Context) { + loaded, err := h.service.ResolveShare(c.Param("token")) + if err != nil { + writeError(c, ErrShareNotFound) + return + } + shareHeaders(c) + h.writeContent(c, loaded, false) +} + +func (h *Handler) load(c *gin.Context) (*SharedDocument, bool) { + userID, projectID, ok := requestScope(c) + if !ok { + return nil, false + } + loaded, err := h.service.Get(userID, projectID, c.Param("documentId")) + if err != nil { + writeError(c, err) + return nil, false + } + return loaded, true +} + +func requestScope(c *gin.Context) (uint, string, bool) { + userID, ok := auth.CurrentUserID(c) + if !ok { + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") + return 0, "", false + } + projectID, ok := httpx.IdentityParam(c, "id") + return userID, projectID, ok +} + +func documentDTO(document models.SaDocument, markdown *string, hasBlob bool) DocumentDTO { + return DocumentDTO{ + ID: document.Identity, ProjectID: document.ProjectIdentity, ParentID: document.ParentIdentity, + Kind: document.Kind, Name: document.Name, Extension: document.Extension, MimeType: document.MimeType, + Size: document.Size, Revision: document.Revision, Markdown: markdown, HasBlob: hasBlob, + SourceInboxItemID: document.SourceInboxItemIdentity, + CreatedAt: document.CreatedAt.UTC(), UpdatedAt: document.UpdatedAt.UTC(), + } +} + +func writeError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, ErrShareNotFound): + httpx.Error(c, http.StatusNotFound, "not_found", "文档不存在") + case errors.Is(err, ErrNameConflict): + httpx.Error(c, http.StatusConflict, "name_conflict", "同一目录已存在同名文档") + case errors.Is(err, ErrRevisionConflict): + httpx.Error(c, http.StatusConflict, "revision_conflict", "文档已在其他窗口更新") + case errors.Is(err, ErrInvalidDocument), errors.Is(err, ErrInvalidParent), errors.Is(err, ErrUnsupportedExport): + httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效") + default: + httpx.Error(c, http.StatusInternalServerError, "internal_error", "文档操作失败") + } +} + +func contentDisposition(kind, name string) string { + return fmt.Sprintf(`%s; filename*=UTF-8''%s`, kind, url.PathEscape(name)) +} + +func requestBaseURL(c *gin.Context) string { + scheme := "http" + if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { + scheme = "https" + } + return scheme + "://" + c.Request.Host +} + +func shareHeaders(c *gin.Context) { + c.Header("X-Robots-Tag", "noindex, nofollow, noarchive") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Referrer-Policy", "no-referrer") + c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; frame-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'") +} + +func filepathExtension(name string) string { + index := strings.LastIndex(name, ".") + if index < 0 { + return "" + } + return name[index:] +} + +func parseInt(value string, fallback int) int { + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/backend/internal/logic/documents/handlers_test.go b/backend/internal/logic/documents/handlers_test.go new file mode 100644 index 0000000..ec747cd --- /dev/null +++ b/backend/internal/logic/documents/handlers_test.go @@ -0,0 +1,32 @@ +package documents + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "senlinai-agent/backend/internal/models" +) + +func TestSharedPreviewBodySanitizesMarkdownHTMLAndUnsafeLinks(t *testing.T) { + body := sharedPreviewBody(SharedDocument{ + Document: models.SaDocument{Name: "shared.md", Extension: ".md"}, + Content: &models.SaDocumentContent{ + Markdown: "# 标题\n\n\n\n[危险链接](javascript:alert(2))", + }, + }, "https://example.test/content") + + require.Contains(t, body, "

标题

") + require.NotContains(t, strings.ToLower(body), " 0 { + if err := tx.Model(&document).Updates(updates).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return ErrNameConflict + } + return err + } + } + if err := tx.Where("id = ?", document.ID).First(&document).Error; err != nil { + return err + } + updated, err = service.loadDocument(document) + return err + }) + return updated, err +} + +func (s *Service) Delete(userID uint, projectIdentity, documentIdentity string) error { + project, err := s.ownedProject(userID, projectIdentity) + if err != nil { + return err + } + var document models.SaDocument + if err := s.database().Where("identity = ? AND project_id = ?", documentIdentity, project.ID).First(&document).Error; err != nil { + return err + } + ids, err := s.subtreeIDs(project.ID, document.ID) + if err != nil { + return err + } + return s.database().Where("id IN ?", ids).Delete(&models.SaDocument{}).Error +} + +func (s *Service) Store(projectIdentity, originalName string, source io.Reader) (StoredBlob, error) { + projectSegment := filepath.Base(strings.TrimSpace(projectIdentity)) + if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) { + return StoredBlob{}, ErrInvalidDocument + } + name := cleanDisplayName(originalName) + if name == "" { + name = "upload.bin" + } + directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment, "documents")) + directoryAbsolute, err := s.absolutePath(directoryRelative) + if err != nil { + return StoredBlob{}, err + } + if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil { + return StoredBlob{}, err + } + temp, err := os.CreateTemp(directoryAbsolute, ".upload-*") + if err != nil { + return StoredBlob{}, err + } + tempName := temp.Name() + defer func() { _ = os.Remove(tempName) }() + hasher := sha256.New() + var sniff bytes.Buffer + writer := io.MultiWriter(temp, hasher, &limitedWriter{writer: &sniff, remaining: 512}) + size, copyErr := io.Copy(writer, source) + closeErr := temp.Close() + if copyErr != nil { + return StoredBlob{}, copyErr + } + if closeErr != nil { + return StoredBlob{}, closeErr + } + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + return StoredBlob{}, err + } + key := hex.EncodeToString(keyBytes) + relative := filepath.ToSlash(filepath.Join(directoryRelative, key)) + absolute, err := s.absolutePath(relative) + if err != nil { + return StoredBlob{}, err + } + if err := os.Rename(tempName, absolute); err != nil { + return StoredBlob{}, err + } + detected := http.DetectContentType(sniff.Bytes()) + extension := strings.ToLower(filepath.Ext(name)) + if extensionType := mime.TypeByExtension(extension); extensionType != "" && detected == "application/octet-stream" { + detected = extensionType + } + return StoredBlob{ + StorageKey: key, RelativePath: relative, AbsolutePath: absolute, OriginalName: name, + MimeType: detected, Extension: extension, Size: size, Checksum: hex.EncodeToString(hasher.Sum(nil)), + }, nil +} + +func (s *Service) OpenBlob(blob models.SaDocumentBlob) (*os.File, error) { + absolute, err := s.absolutePath(blob.RelativePath) + if err != nil { + return nil, err + } + return os.Open(absolute) +} + +func (s *Service) PreviewLegacySheet(loaded SharedDocument, maxRows, maxColumns int) (SheetPreview, error) { + if loaded.Document.Extension != ".xls" || loaded.Blob == nil { + return SheetPreview{}, ErrInvalidDocument + } + file, err := s.OpenBlob(*loaded.Blob) + if err != nil { + return SheetPreview{}, err + } + defer file.Close() + workbook, err := xls.OpenReader(file) + if err != nil { + return SheetPreview{}, ErrInvalidDocument + } + sheet, err := workbook.GetSheet(0) + if err != nil { + return SheetPreview{}, ErrInvalidDocument + } + sourceRows := sheet.GetRows() + preview := SheetPreview{SheetName: sheet.GetName(), Truncated: len(sourceRows) > maxRows} + if len(sourceRows) > maxRows { + sourceRows = sourceRows[:maxRows] + } + preview.Rows = make([][]string, 0, len(sourceRows)) + for _, sourceRow := range sourceRows { + columns := sourceRow.GetCols() + if len(columns) > maxColumns { + columns = columns[:maxColumns] + preview.Truncated = true + } + row := make([]string, 0, len(columns)) + for _, column := range columns { + row = append(row, column.GetString()) + } + preview.Rows = append(preview.Rows, row) + } + return preview, nil +} + +func (s *Service) CreateShare(userID uint, projectIdentity, documentIdentity string, duration time.Duration) (*models.SaDocumentShare, string, error) { + loaded, err := s.Get(userID, projectIdentity, documentIdentity) + if err != nil { + return nil, "", err + } + if loaded.Document.Kind != models.DocumentKindFile { + return nil, "", ErrInvalidDocument + } + if duration <= 0 || duration > 30*24*time.Hour { + return nil, "", ErrInvalidDocument + } + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, "", err + } + token := hex.EncodeToString(tokenBytes) + hash := sha256.Sum256([]byte(token)) + share := &models.SaDocumentShare{ + DocumentID: loaded.Document.ID, CreatedBy: userID, TokenHash: hex.EncodeToString(hash[:]), + ExpiresAt: s.now().UTC().Add(duration), + } + return share, token, s.database().Create(share).Error +} + +func (s *Service) ListShares(userID uint, projectIdentity, documentIdentity string) ([]models.SaDocumentShare, error) { + loaded, err := s.Get(userID, projectIdentity, documentIdentity) + if err != nil { + return nil, err + } + var shares []models.SaDocumentShare + err = s.database().Where("document_id = ?", loaded.Document.ID).Order("created_at desc").Find(&shares).Error + return shares, err +} + +func (s *Service) RevokeShare(userID uint, projectIdentity, documentIdentity, shareIdentity string) error { + loaded, err := s.Get(userID, projectIdentity, documentIdentity) + if err != nil { + return err + } + now := s.now().UTC() + result := s.database().Model(&models.SaDocumentShare{}). + Where("identity = ? AND document_id = ?", shareIdentity, loaded.Document.ID). + Update("revoked_at", &now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *Service) ResolveShare(token string) (*SharedDocument, error) { + hash := sha256.Sum256([]byte(strings.TrimSpace(token))) + var share models.SaDocumentShare + if err := s.database().Where( + "token_hash = ? AND revoked_at IS NULL AND expires_at > ?", + hex.EncodeToString(hash[:]), s.now().UTC(), + ).First(&share).Error; err != nil { + return nil, ErrShareNotFound + } + var document models.SaDocument + if err := s.database().Where("id = ?", share.DocumentID).First(&document).Error; err != nil { + return nil, ErrShareNotFound + } + return s.loadDocument(document) +} + +func (s *Service) Export(document SharedDocument, format string) ([]byte, string, string, error) { + if document.Content == nil || document.Document.Extension != ".md" { + return nil, "", "", ErrUnsupportedExport + } + base := strings.TrimSuffix(document.Document.Name, filepath.Ext(document.Document.Name)) + switch strings.ToLower(strings.TrimSpace(format)) { + case "md", "markdown": + return []byte(document.Content.Markdown), base + ".md", "text/markdown; charset=utf-8", nil + case "docx": + data, err := markdownDOCX(document.Content.Markdown) + return data, base + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", err + case "pdf": + data, err := markdownPDF(document.Content.Markdown) + return data, base + ".pdf", "application/pdf", err + default: + return nil, "", "", ErrUnsupportedExport + } +} + +func (s *Service) loadDocument(document models.SaDocument) (*SharedDocument, error) { + result := &SharedDocument{Document: document} + var content models.SaDocumentContent + if err := s.database().Where("document_id = ?", document.ID).First(&content).Error; err == nil { + result.Content = &content + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + var blob models.SaDocumentBlob + if err := s.database().Where("document_id = ?", document.ID).First(&blob).Error; err == nil { + result.Blob = &blob + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + return result, nil +} + +func (s *Service) ownedProject(userID uint, identity string) (*models.SaProject, error) { + var project models.SaProject + err := s.database().Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&project).Error + return &project, err +} + +func (s *Service) resolveParent(projectID uint, identity string) (*uint, *string, error) { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil, nil, nil + } + var parent models.SaDocument + if err := s.database().Where("identity = ? AND project_id = ? AND kind = ?", identity, projectID, models.DocumentKindFolder).First(&parent).Error; err != nil { + return nil, nil, ErrInvalidParent + } + return &parent.ID, &parent.Identity, nil +} + +func (s *Service) availableName(projectID uint, parentID *uint, name string) (string, error) { + extension := filepath.Ext(name) + base := strings.TrimSuffix(name, extension) + for attempt := 0; attempt < 1000; attempt++ { + candidate := name + if attempt > 0 { + candidate = fmt.Sprintf("%s (%d)%s", base, attempt, extension) + } + if !s.nameExists(projectID, parentID, normalizeName(candidate), 0) { + return candidate, nil + } + } + return "", ErrNameConflict +} + +func (s *Service) nameExists(projectID uint, parentID *uint, normalized string, exceptID uint) bool { + query := s.database().Model(&models.SaDocument{}).Where("project_id = ? AND normalized_name = ?", projectID, normalized) + if parentID == nil { + query = query.Where("parent_id IS NULL") + } else { + query = query.Where("parent_id = ?", *parentID) + } + if exceptID > 0 { + query = query.Where("id <> ?", exceptID) + } + var count int64 + return query.Count(&count).Error == nil && count > 0 +} + +func (s *Service) isDescendant(ancestorID, candidateID uint) (bool, error) { + current := candidateID + for current != 0 { + if current == ancestorID { + return true, nil + } + var document models.SaDocument + if err := s.database().Unscoped().Select("parent_id").Where("id = ?", current).First(&document).Error; err != nil { + return false, err + } + if document.ParentID == nil { + return false, nil + } + current = *document.ParentID + } + return false, nil +} + +func (s *Service) subtreeIDs(projectID, rootID uint) ([]uint, error) { + result := []uint{rootID} + frontier := []uint{rootID} + for len(frontier) > 0 { + var children []models.SaDocument + if err := s.database().Select("id").Where("project_id = ? AND parent_id IN ?", projectID, frontier).Find(&children).Error; err != nil { + return nil, err + } + frontier = frontier[:0] + for _, child := range children { + result = append(result, child.ID) + frontier = append(frontier, child.ID) + } + } + return result, nil +} + +func (s *Service) absolutePath(relative string) (string, error) { + cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative))) + if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", ErrInvalidDocument + } + root, err := filepath.Abs(s.root) + if err != nil { + return "", err + } + absolute, err := filepath.Abs(filepath.Join(root, cleaned)) + if err != nil { + return "", err + } + relativeToRoot, err := filepath.Rel(root, absolute) + if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) { + return "", ErrInvalidDocument + } + return absolute, nil +} + +func cleanDisplayName(value string) string { + return strings.TrimSpace(filepath.Base(strings.ReplaceAll(value, "\\", "/"))) +} + +func normalizeName(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + return unicode.ToLower(r) + }, strings.TrimSpace(value)) +} + +func identityValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +type limitedWriter struct { + writer io.Writer + remaining int +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + if w.remaining <= 0 { + return len(p), nil + } + size := len(p) + if size > w.remaining { + size = w.remaining + } + _, _ = w.writer.Write(p[:size]) + w.remaining -= size + return len(p), nil +} + +var markdownPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?`) + +func plainMarkdown(value string) string { + value = markdownPrefix.ReplaceAllString(value, "") + value = strings.NewReplacer("**", "", "__", "", "`", "", "\r", "").Replace(value) + return value +} + +func markdownDOCX(markdown string) ([]byte, error) { + var buffer bytes.Buffer + archive := zip.NewWriter(&buffer) + files := map[string]string{ + "[Content_Types].xml": ``, + "_rels/.rels": ``, + "word/document.xml": docxDocumentXML(markdown), + } + for name, body := range files { + writer, err := archive.Create(name) + if err != nil { + return nil, err + } + if _, err := io.WriteString(writer, body); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func docxDocumentXML(markdown string) string { + var paragraphs strings.Builder + for _, line := range strings.Split(plainMarkdown(markdown), "\n") { + paragraphs.WriteString(``) + paragraphs.WriteString(html.EscapeString(line)) + paragraphs.WriteString(``) + } + return `` + + paragraphs.String() + `` +} + +func markdownPDF(markdown string) ([]byte, error) { + const ( + pageWidth = 595.28 + pageHeight = 841.89 + marginX = 52.0 + top = 56.0 + bottom = 60.0 + ) + pdf := &gopdf.GoPdf{} + pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageWidth, H: pageHeight}}) + if err := pdf.AddTTFFontData("NotoSansSC", documentPDFFont); err != nil { + return nil, fmt.Errorf("load document PDF font: %w", err) + } + pageNumber := 0 + y := top + addPage := func() error { + if pageNumber > 0 { + if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil { + return err + } + } + pdf.AddPage() + pageNumber++ + y = top + return nil + } + if err := addPage(); err != nil { + return nil, err + } + for _, rawLine := range strings.Split(strings.ReplaceAll(markdown, "\r", ""), "\n") { + line := plainMarkdown(rawLine) + fontSize := 11.0 + lineHeight := 18.0 + switch { + case strings.HasPrefix(rawLine, "# "): + fontSize, lineHeight = 22, 32 + case strings.HasPrefix(rawLine, "## "): + fontSize, lineHeight = 18, 27 + case strings.HasPrefix(rawLine, "### "): + fontSize, lineHeight = 15, 23 + case strings.TrimSpace(rawLine) == "": + y += 9 + continue + } + if err := pdf.SetFont("NotoSansSC", "", fontSize); err != nil { + return nil, err + } + lines, err := pdf.SplitTextWithWordWrap(line, pageWidth-2*marginX) + if err != nil { + return nil, err + } + if len(lines) == 0 { + lines = []string{""} + } + for _, wrapped := range lines { + if y+lineHeight > pageHeight-bottom { + if err := addPage(); err != nil { + return nil, err + } + } + pdf.SetX(marginX) + pdf.SetY(y) + if err := pdf.Cell(&gopdf.Rect{W: pageWidth - 2*marginX, H: lineHeight}, wrapped); err != nil { + return nil, err + } + y += lineHeight + } + y += 3 + } + if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil { + return nil, err + } + var buffer bytes.Buffer + if err := pdf.Write(&buffer); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func writePDFFooter(pdf *gopdf.GoPdf, pageNumber int, pageWidth, pageHeight float64) error { + if err := pdf.SetFont("NotoSansSC", "", 9); err != nil { + return err + } + pdf.SetTextColor(120, 120, 120) + pdf.SetX(0) + pdf.SetY(pageHeight - 34) + err := pdf.CellWithOption(&gopdf.Rect{W: pageWidth, H: 14}, fmt.Sprintf("第 %d 页", pageNumber), gopdf.CellOption{Align: gopdf.Center}) + pdf.SetTextColor(30, 30, 30) + return err +} diff --git a/backend/internal/logic/documents/service_test.go b/backend/internal/logic/documents/service_test.go new file mode 100644 index 0000000..70115d1 --- /dev/null +++ b/backend/internal/logic/documents/service_test.go @@ -0,0 +1,126 @@ +package documents + +import ( + "bytes" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "senlinai-agent/backend/internal/models" +) + +func TestDocumentTreeRevisionMoveDeleteAndShareLifecycle(t *testing.T) { + service, database, user, project := newDocumentTestService(t) + + folder, err := service.CreateFolder(user.ID, project.Identity, CreateInput{Name: "产品"}) + require.NoError(t, err) + child, err := service.CreateFolder(user.ID, project.Identity, CreateInput{Name: "方案", ParentIdentity: folder.Identity}) + require.NoError(t, err) + document, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{ + Name: "说明.md", ParentIdentity: child.Identity, Markdown: "# 第一版", + }) + require.NoError(t, err) + + loaded, err := service.Get(user.ID, project.Identity, document.Identity) + require.NoError(t, err) + require.Equal(t, "# 第一版", loaded.Content.Markdown) + revision := loaded.Document.Revision + nextMarkdown := "# 第二版" + updated, err := service.Update(user.ID, project.Identity, document.Identity, UpdateInput{ + Markdown: &nextMarkdown, Revision: &revision, + }) + require.NoError(t, err) + require.Equal(t, revision+1, updated.Document.Revision) + + stale := "# 陈旧覆盖" + _, err = service.Update(user.ID, project.Identity, document.Identity, UpdateInput{ + Markdown: &stale, Revision: &revision, + }) + require.ErrorIs(t, err, ErrRevisionConflict) + + parent := child.Identity + _, err = service.Update(user.ID, project.Identity, folder.Identity, UpdateInput{ParentIdentity: &parent}) + require.ErrorIs(t, err, ErrInvalidParent) + + share, token, err := service.CreateShare(user.ID, project.Identity, document.Identity, 24*time.Hour) + require.NoError(t, err) + require.NotEmpty(t, token) + require.NotEqual(t, token, share.TokenHash) + shared, err := service.ResolveShare(token) + require.NoError(t, err) + require.Equal(t, document.Identity, shared.Document.Identity) + require.NoError(t, service.RevokeShare(user.ID, project.Identity, document.Identity, share.Identity)) + _, err = service.ResolveShare(token) + require.ErrorIs(t, err, ErrShareNotFound) + + require.NoError(t, service.Delete(user.ID, project.Identity, folder.Identity)) + var visible int64 + require.NoError(t, database.Model(&models.SaDocument{}).Where("project_id = ?", project.ID).Count(&visible).Error) + require.Zero(t, visible) + var retained int64 + require.NoError(t, database.Unscoped().Model(&models.SaDocument{}).Where("project_id = ?", project.ID).Count(&retained).Error) + require.Equal(t, int64(3), retained) +} + +func TestDocumentUploadUsesOpaqueStorageAndMarkdownNamesAreDeduplicated(t *testing.T) { + service, _, user, project := newDocumentTestService(t) + + first, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "记录.md", Markdown: "first"}) + require.NoError(t, err) + second, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "记录.md", Markdown: "second"}) + require.NoError(t, err) + require.Equal(t, "记录.md", first.Name) + require.Equal(t, "记录 (1).md", second.Name) + + content := bytes.Repeat([]byte("pdf-content"), 128) + stored, err := service.Store(project.Identity, "../../合同.pdf", bytes.NewReader(content)) + require.NoError(t, err) + require.Equal(t, "合同.pdf", stored.OriginalName) + require.NotContains(t, stored.RelativePath, "合同.pdf") + require.FileExists(t, stored.AbsolutePath) + uploaded, err := service.CreateUploaded(user.ID, project.Identity, "", stored) + require.NoError(t, err) + require.Equal(t, ".pdf", uploaded.Extension) + require.Equal(t, int64(len(content)), uploaded.Size) + file, err := os.Open(stored.AbsolutePath) + require.NoError(t, err) + defer file.Close() +} + +func TestDocumentExportProducesDownloadableFormats(t *testing.T) { + service, _, user, project := newDocumentTestService(t) + document, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "导出.md", Markdown: "# 标题\n\n正文"}) + require.NoError(t, err) + loaded, err := service.Get(user.ID, project.Identity, document.Identity) + require.NoError(t, err) + + for _, format := range []string{"md", "docx", "pdf"} { + data, name, contentType, err := service.Export(*loaded, format) + require.NoError(t, err) + require.NotEmpty(t, data) + require.Equal(t, "导出."+format, name) + require.NotEmpty(t, contentType) + } +} + +func newDocumentTestService(t *testing.T) (*Service, *gorm.DB, models.SaUser, models.SaProject) { + t.Helper() + database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true}) + require.NoError(t, err) + require.NoError(t, models.AutoMigrate(database)) + user := models.SaUser{Email: t.Name() + "@example.com", DisplayName: "Owner", PasswordHash: "hash"} + require.NoError(t, database.Create(&user).Error) + project := models.SaProject{OwnerID: user.ID, Name: "Documents", Identifier: "DOCS-" + fmt.Sprint(time.Now().UnixNano())} + require.NoError(t, database.Create(&project).Error) + service := NewService(database, t.TempDir()) + service.now = func() time.Time { return time.Date(2026, 7, 24, 0, 0, 0, 0, time.UTC) } + t.Cleanup(func() { + require.False(t, errors.Is(database.Error, gorm.ErrInvalidDB)) + }) + return service, database, user, project +} diff --git a/backend/internal/logic/files/handlers.go b/backend/internal/logic/files/handlers.go deleted file mode 100644 index 702a504..0000000 --- a/backend/internal/logic/files/handlers.go +++ /dev/null @@ -1,122 +0,0 @@ -package files - -import ( - "errors" - "log" - "net/http" - "path/filepath" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "senlinai-agent/backend/internal/httpx" - "senlinai-agent/backend/internal/logic/auth" - "senlinai-agent/backend/internal/logic/projects" - "senlinai-agent/backend/internal/models" -) - -// SourceDTO 是文件资料写接口的稳定响应,仅返回 opaque storage key,不暴露物理目录布局。 -type SourceDTO struct { - ID string `json:"id"` - ProjectID string `json:"projectId"` - Kind string `json:"kind"` - Title string `json:"title"` - StorageKey string `json:"storageKey"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Handler 负责 multipart 边界,文件名清理和本地路径构造始终委托给 Service。 -type Handler struct { - service *Service - maxUploadBytes int64 -} - -// DefaultMaxUploadBytes 是未显式配置时的请求体上限,覆盖完整 multipart 内容。 -const DefaultMaxUploadBytes int64 = 32 << 20 - -// NewHandler 创建文件资料 HTTP registrar。 -func NewHandler(service *Service, limits ...int64) *Handler { - limit := DefaultMaxUploadBytes - if len(limits) > 0 && limits[0] > 0 { - limit = limits[0] - } - return &Handler{service: service, maxUploadBytes: limit} -} - -// Register 将文件资料上传接口注册到上层提供的 /api/v1 路由组。 -func (h *Handler) Register(router gin.IRouter) { - router.POST("/projects/:id/sources", h.upload) -} - -func (h *Handler) upload(c *gin.Context) { - userID, ok := auth.CurrentUserID(c) - if !ok { - httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") - return - } - projectIdentity, ok := httpx.IdentityParam(c, "id") - if !ok { - return - } - // 所有权必须在写磁盘前校验,防止越权请求留下孤立文件。 - project, err := projects.FindOwnedProject(userID, projectIdentity) - if err != nil { - writeSourceError(c, err) - return - } - // MaxBytesReader 必须在任何 multipart 解析前安装,限制字段、边界和文件内容的总请求量。 - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes) - fileHeader, err := c.FormFile("file") - if err != nil { - var maxBytesError *http.MaxBytesError - if errors.As(err, &maxBytesError) { - httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "上传内容超过大小限制") - return - } - httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件") - return - } - file, err := fileHeader.Open() - if err != nil { - httpx.Error(c, http.StatusBadRequest, "invalid_request", "文件读取失败") - return - } - defer file.Close() - - // 先由文件服务保存内容,再用其返回的相对路径创建资料记录;handler 不拼接任何本地路径。 - stored, err := h.service.Save(project.Identity, fileHeader.Filename, file) - if err != nil { - httpx.Error(c, http.StatusInternalServerError, "internal_error", "文件保存失败") - return - } - source, err := h.service.CreateSource(userID, project, c.PostForm("title"), stored) - if err != nil { - if cleanupErr := h.service.Remove(stored); cleanupErr != nil { - // 清理错误仅写服务端日志,响应继续使用稳定中文错误,不暴露 storage root。 - log.Printf("source file cleanup failed: %v", cleanupErr) - } - writeSourceError(c, err) - return - } - c.JSON(http.StatusCreated, sourceDTO(*source)) -} - -func sourceDTO(source models.SaSource) SourceDTO { - return SourceDTO{ - ID: source.Identity, ProjectID: source.ProjectIdentity, Kind: source.Kind, - Title: source.Title, StorageKey: filepath.Base(filepath.FromSlash(source.FilePath)), - CreatedAt: source.CreatedAt.UTC(), UpdatedAt: source.UpdatedAt.UTC(), - } -} - -func writeSourceError(c *gin.Context, err error) { - switch { - case errors.Is(err, gorm.ErrRecordNotFound): - httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在") - case errors.Is(err, ErrSourceTitleRequired), errors.Is(err, ErrSourcePathRequired): - httpx.Error(c, http.StatusBadRequest, "invalid_request", "资料参数无效") - default: - httpx.Error(c, http.StatusInternalServerError, "internal_error", "资料创建失败") - } -} diff --git a/backend/internal/logic/files/handlers_test.go b/backend/internal/logic/files/handlers_test.go deleted file mode 100644 index 4f62705..0000000 --- a/backend/internal/logic/files/handlers_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package files - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "log" - "mime/multipart" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/glebarez/sqlite" - "github.com/stretchr/testify/require" - "gorm.io/gorm" - "senlinai-agent/backend/internal/config" - "senlinai-agent/backend/internal/httpx" - "senlinai-agent/backend/internal/models" -) - -func TestFileRegistrarSavesBeforeCreatingIdentitySourceDTO(t *testing.T) { - router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 1) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - require.NoError(t, writer.WriteField("title", "客户资料")) - part, err := writer.CreateFormFile("file", "客户资料.txt") - require.NoError(t, err) - _, err = part.Write([]byte("private content")) - require.NoError(t, err) - require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String()) - var source models.SaSource - require.NoError(t, database.Where("project_id = ?", project.ID).First(&source).Error) - require.FileExists(t, filepath.Join(storageRoot, filepath.FromSlash(source.FilePath))) - require.Contains(t, filepath.ToSlash(source.FilePath), "projects/"+project.Identity+"/") - require.NotContains(t, filepath.ToSlash(source.FilePath), fmt.Sprintf("projects/%d/", project.ID)) - var payload map[string]any - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) - require.Equal(t, source.Identity, payload["id"]) - require.Equal(t, project.Identity, payload["projectId"]) - require.Equal(t, filepath.Base(source.FilePath), payload["storageKey"]) - require.NotContains(t, payload, "filePath") - require.NotContains(t, payload, "AbsolutePath") - require.NotContains(t, rec.Body.String(), storageRoot) - require.NotContains(t, rec.Body.String(), fmt.Sprintf("projects/%d/", project.ID)) -} - -func TestFileRegistrarChecksOwnershipBeforeWritingFile(t *testing.T) { - router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 2) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", "hidden.txt") - require.NoError(t, err) - _, err = part.Write([]byte("must not be stored")) - require.NoError(t, err) - require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) - var count int64 - require.NoError(t, database.Model(&models.SaSource{}).Count(&count).Error) - require.Zero(t, count) - entries, err := os.ReadDir(storageRoot) - require.NoError(t, err) - require.Empty(t, entries) -} - -func TestFileRegistrarRemovesStoredFileWhenSourceCreateFails(t *testing.T) { - router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 1) - require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_source_create", func(tx *gorm.DB) { - if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SaSource{}).TableName() { - tx.AddError(errors.New("forced source create failure")) - } - })) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", "orphan.txt") - require.NoError(t, err) - _, err = part.Write([]byte("must be compensated")) - require.NoError(t, err) - require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String()) - require.NotContains(t, rec.Body.String(), storageRoot) - entries, err := os.ReadDir(storageRoot) - require.NoError(t, err) - require.Empty(t, entries, "source 元数据失败后存储目录应为空") -} - -func TestFileRegistrarRejectsRequestOverConfiguredUploadLimit(t *testing.T) { - router, _, project, storageRoot := newFileHandlerTestRouterWithLimit(t, 1, 1, 256) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", "oversize.bin") - require.NoError(t, err) - _, err = part.Write(bytes.Repeat([]byte("x"), 1024)) - require.NoError(t, err) - require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, rec.Body.String()) - var payload httpx.ErrorEnvelope - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) - require.Equal(t, "payload_too_large", payload.Error.Code) - require.Equal(t, "上传内容超过大小限制", payload.Error.Message) - entries, err := os.ReadDir(storageRoot) - require.NoError(t, err) - require.Empty(t, entries) -} - -func TestFileRegistrarLogsCleanupFailureWithoutLeakingStorageRoot(t *testing.T) { - router, database, project, storageRoot, service := newFileHandlerTestRouterWithService(t, 1, 1, DefaultMaxUploadBytes) - require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_source_create_for_cleanup_log", func(tx *gorm.DB) { - if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SaSource{}).TableName() { - tx.AddError(errors.New("forced source create failure")) - } - })) - service.remove = func(path string) error { - if strings.HasPrefix(filepath.Base(path), ".upload-") { - return os.Remove(path) - } - return fmt.Errorf("cleanup blocked for %s", path) - } - var serverLog bytes.Buffer - previousLogOutput := log.Writer() - log.SetOutput(&serverLog) - t.Cleanup(func() { log.SetOutput(previousLogOutput) }) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("file", "cleanup-failure.txt") - require.NoError(t, err) - _, err = part.Write([]byte("content")) - require.NoError(t, err) - require.NoError(t, writer.Close()) - req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body) - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer test-token") - rec := httptest.NewRecorder() - - router.ServeHTTP(rec, req) - - require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String()) - require.NotContains(t, rec.Body.String(), storageRoot) - require.Contains(t, serverLog.String(), "source file cleanup failed") - require.Contains(t, serverLog.String(), storageRoot) -} - -func newFileHandlerTestRouter(t *testing.T, currentUserID, ownerID uint) (*gin.Engine, *gorm.DB, models.SaProject, string) { - return newFileHandlerTestRouterWithLimit(t, currentUserID, ownerID, DefaultMaxUploadBytes) -} - -func newFileHandlerTestRouterWithLimit(t *testing.T, currentUserID, ownerID uint, maxUploadBytes int64) (*gin.Engine, *gorm.DB, models.SaProject, string) { - router, database, project, storageRoot, _ := newFileHandlerTestRouterWithService(t, currentUserID, ownerID, maxUploadBytes) - return router, database, project, storageRoot -} - -func newFileHandlerTestRouterWithService(t *testing.T, currentUserID, ownerID uint, maxUploadBytes int64) (*gin.Engine, *gorm.DB, models.SaProject, string, *Service) { - t.Helper() - database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true}) - require.NoError(t, err) - require.NoError(t, models.AutoMigrate(database)) - models.DBService = database - require.NoError(t, database.Create(&models.SaUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error) - require.NoError(t, database.Create(&models.SaUser{Email: "other@example.com", DisplayName: "Other", PasswordHash: "hash"}).Error) - project := models.SaProject{OwnerID: ownerID, Name: "Files", Identifier: fmt.Sprintf("FILES-%d", ownerID)} - require.NoError(t, database.Create(&project).Error) - storageRoot := t.TempDir() - service := NewService(storageRoot, database) - router := httpx.NewProtectedRouter( - config.Config{Env: "test"}, - func(string) (uint, error) { return currentUserID, nil }, - NewHandler(service, maxUploadBytes), - ) - return router, database, project, storageRoot, service -} diff --git a/backend/internal/logic/files/service.go b/backend/internal/logic/files/service.go deleted file mode 100644 index 3fce8fc..0000000 --- a/backend/internal/logic/files/service.go +++ /dev/null @@ -1,216 +0,0 @@ -package files - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "io" - "os" - "path/filepath" - "strings" - - "gorm.io/gorm" - "senlinai-agent/backend/internal/models" -) - -type Service struct { - root string - db *gorm.DB - createTemp func(string, string) (stagedFile, error) - publish func(string, string) error - remove func(string) error - newStorageKey func() (string, error) -} - -type stagedFile interface { - io.Writer - Close() error - Name() string -} - -type StoredFile struct { - OriginalName string - StorageKey string - RelativePath string - AbsolutePath string -} - -// NewService 集中持有存储根目录;任何服务端本地路径都只能由该服务构造。 -func NewService(root string, databases ...*gorm.DB) *Service { - var database *gorm.DB - if len(databases) > 0 { - database = databases[0] - } - return &Service{ - root: root, db: database, - createTemp: func(directory, pattern string) (stagedFile, error) { return os.CreateTemp(directory, pattern) }, - publish: os.Link, - remove: os.Remove, - newStorageKey: randomStorageKey, - } -} - -// Save 使用项目公开 identity 和随机 opaque key 定位文件;硬链接发布提供跨请求的排他创建语义。 -func (s *Service) Save(projectIdentity string, originalName string, content io.Reader) (StoredFile, error) { - cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/")) - if cleanName == "." || cleanName == "" { - cleanName = "upload.bin" - } - projectSegment := filepath.Base(strings.ReplaceAll(strings.TrimSpace(projectIdentity), "\\", "/")) - if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) { - return StoredFile{}, ErrSourcePathRequired - } - directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment)) - directoryAbsolute, err := s.absolutePath(directoryRelative) - if err != nil { - return StoredFile{}, err - } - if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil { - return StoredFile{}, err - } - // 临时文件与最终文件位于同一目录,完整关闭后再排他发布,避免暴露半写入内容。 - file, err := s.createTemp(directoryAbsolute, ".upload-*") - if err != nil { - return StoredFile{}, err - } - if _, err := io.Copy(file, content); err != nil { - _ = file.Close() - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, err - } - if err := file.Close(); err != nil { - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, err - } - for attempt := 0; attempt < storageKeyAttempts; attempt++ { - storageKey, err := s.newStorageKey() - if err != nil { - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, err - } - relative := filepath.ToSlash(filepath.Join(directoryRelative, storageKey)) - absolute, err := s.absolutePath(relative) - if err != nil { - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, err - } - if err := s.publish(file.Name(), absolute); err != nil { - if errors.Is(err, os.ErrExist) { - continue - } - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, err - } - if err := s.remove(file.Name()); err != nil { - // final path 由本次排他发布创建,因此这里只清理本次请求拥有的两个路径。 - s.cleanupOwnedFiles(file.Name(), absolute) - return StoredFile{}, err - } - return StoredFile{OriginalName: cleanName, StorageKey: storageKey, RelativePath: relative, AbsolutePath: absolute}, nil - } - s.cleanupOwnedFiles(file.Name()) - return StoredFile{}, ErrStorageKeyCollision -} - -// Remove 只依据 Service 生成的相对路径定位文件,并尽量移除直至存储根目录的空父目录。 -func (s *Service) Remove(stored StoredFile) error { - absolute, err := s.absolutePath(stored.RelativePath) - if err != nil { - return err - } - if err := s.remove(absolute); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - root, err := filepath.Abs(s.root) - if err != nil { - return err - } - for directory := filepath.Dir(absolute); directory != root; directory = filepath.Dir(directory) { - entries, err := os.ReadDir(directory) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return err - } - if len(entries) > 0 { - return nil - } - if err := s.remove(directory); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - } - return nil -} - -func (s *Service) absolutePath(relativePath string) (string, error) { - cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath))) - if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - return "", ErrSourcePathRequired - } - root, err := filepath.Abs(s.root) - if err != nil { - return "", err - } - absolute, err := filepath.Abs(filepath.Join(root, cleaned)) - if err != nil { - return "", err - } - relativeToRoot, err := filepath.Rel(root, absolute) - if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) { - return "", ErrSourcePathRequired - } - return absolute, nil -} - -func (s *Service) cleanupOwnedFiles(paths ...string) { - for _, path := range paths { - _ = s.remove(path) - } -} - -func (s *Service) database() *gorm.DB { - if s.db != nil { - return s.db - } - return models.DBService -} - -var ( - ErrSourceTitleRequired = errors.New("source title is required") - ErrSourcePathRequired = errors.New("source file path is required") - ErrStorageKeyCollision = errors.New("unable to allocate unique storage key") -) - -const storageKeyAttempts = 8 - -func randomStorageKey() (string, error) { - buffer := make([]byte, 16) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return hex.EncodeToString(buffer), nil -} - -// CreateSource 只持久化 Save 产生的相对路径,不接受 handler 自行拼接本地路径。 -func (s *Service) CreateSource(ownerID uint, project *models.SaProject, title string, stored StoredFile) (*models.SaSource, error) { - title = strings.TrimSpace(title) - if title == "" { - title = stored.OriginalName - } - if title == "" { - return nil, ErrSourceTitleRequired - } - relativePath := filepath.ToSlash(strings.TrimSpace(stored.RelativePath)) - if relativePath == "" || filepath.IsAbs(relativePath) { - return nil, ErrSourcePathRequired - } - source := &models.SaSource{ - ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: ownerID, - Kind: "file", Title: title, FilePath: relativePath, - } - if err := s.database().Create(source).Error; err != nil { - return nil, err - } - return source, nil -} diff --git a/backend/internal/logic/files/service_test.go b/backend/internal/logic/files/service_test.go deleted file mode 100644 index ae47a68..0000000 --- a/backend/internal/logic/files/service_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package files - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -type failingReader struct { - sent bool -} - -func (r *failingReader) Read(buffer []byte) (int, error) { - if !r.sent { - r.sent = true - return copy(buffer, "partial"), nil - } - return 0, errors.New("copy failed") -} - -type closeFailingFile struct { - *os.File -} - -func (f *closeFailingFile) Close() error { - _ = f.File.Close() - return errors.New("close failed") -} - -func TestSaveStoresFileUnderProjectIdentityWithOpaqueKey(t *testing.T) { - service := NewService(t.TempDir()) - projectIdentity := "019b0000-0000-7000-8000-000000000012" - - stored, err := service.Save(projectIdentity, "brief.md", strings.NewReader("hello")) - - require.NoError(t, err) - require.Equal(t, "brief.md", stored.OriginalName) - require.Contains(t, stored.RelativePath, "projects/"+projectIdentity+"/") - require.NotContains(t, stored.RelativePath, "projects/12/") - require.Regexp(t, `^[a-f0-9]{32}$`, stored.StorageKey) - require.Equal(t, stored.StorageKey, filepath.Base(stored.RelativePath)) - require.FileExists(t, stored.AbsolutePath) -} - -func TestSaveNeutralizesPathTraversal(t *testing.T) { - service := NewService(t.TempDir()) - - stored, err := service.Save("019b0000-0000-7000-8000-000000000012", "..\\..\\secret.txt", strings.NewReader("hello")) - - require.NoError(t, err) - require.Equal(t, "secret.txt", stored.OriginalName) - require.NotContains(t, stored.RelativePath, "secret.txt") -} - -func TestSaveRemovesTemporaryAndPartialFilesWhenCopyFails(t *testing.T) { - storageRoot := t.TempDir() - service := NewService(storageRoot) - - _, err := service.Save("019b0000-0000-7000-8000-000000000012", "broken.bin", &failingReader{}) - - require.ErrorContains(t, err, "copy failed") - requireNoStoredFiles(t, storageRoot) -} - -func TestSaveRemovesTemporaryFileWhenCloseFails(t *testing.T) { - storageRoot := t.TempDir() - service := NewService(storageRoot) - service.createTemp = func(directory, pattern string) (stagedFile, error) { - file, err := os.CreateTemp(directory, pattern) - if err != nil { - return nil, err - } - return &closeFailingFile{File: file}, nil - } - - _, err := service.Save("019b0000-0000-7000-8000-000000000012", "broken.bin", strings.NewReader("content")) - - require.ErrorContains(t, err, "close failed") - requireNoStoredFiles(t, storageRoot) -} - -func TestSaveCollisionNeverOverwritesOrDeletesExistingFile(t *testing.T) { - storageRoot := t.TempDir() - service := NewService(storageRoot) - service.newStorageKey = func() (string, error) { return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil } - projectIdentity := "019b0000-0000-7000-8000-000000000012" - first, err := service.Save(projectIdentity, "first.bin", strings.NewReader("first owner")) - require.NoError(t, err) - - _, err = service.Save(projectIdentity, "second.bin", strings.NewReader("second owner")) - - require.ErrorIs(t, err, ErrStorageKeyCollision) - content, readErr := os.ReadFile(first.AbsolutePath) - require.NoError(t, readErr) - require.Equal(t, "first owner", string(content)) - requireOnlyStoredFile(t, storageRoot, first.AbsolutePath) -} - -func TestConcurrentSaveWithSameKeyPublishesExactlyOneOwner(t *testing.T) { - storageRoot := t.TempDir() - service := NewService(storageRoot) - service.newStorageKey = func() (string, error) { return "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil } - projectIdentity := "019b0000-0000-7000-8000-000000000012" - start := make(chan struct{}) - type result struct { - stored StoredFile - err error - } - results := make(chan result, 2) - var ready sync.WaitGroup - ready.Add(2) - for _, body := range []string{"alpha", "beta"} { - go func(content string) { - ready.Done() - <-start - stored, err := service.Save(projectIdentity, content+".bin", strings.NewReader(content)) - results <- result{stored: stored, err: err} - }(body) - } - ready.Wait() - close(start) - firstResult, secondResult := <-results, <-results - - errors := []error{firstResult.err, secondResult.err} - require.Equal(t, 1, countNilErrors(errors)) - require.Equal(t, 1, countMatchingErrors(errors, ErrStorageKeyCollision)) - winner := firstResult.stored - if firstResult.err != nil { - winner = secondResult.stored - } - require.FileExists(t, winner.AbsolutePath) - requireOnlyStoredFile(t, storageRoot, winner.AbsolutePath) -} - -func requireNoStoredFiles(t *testing.T, storageRoot string) { - t.Helper() - require.NoError(t, filepath.WalkDir(storageRoot, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if path != storageRoot && !entry.IsDir() { - t.Fatalf("unexpected stored file after failure: %s", path) - } - return nil - })) -} - -func requireOnlyStoredFile(t *testing.T, storageRoot, expectedPath string) { - t.Helper() - files := []string{} - require.NoError(t, filepath.WalkDir(storageRoot, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if !entry.IsDir() { - files = append(files, path) - } - return nil - })) - require.Equal(t, []string{expectedPath}, files) -} - -func countNilErrors(values []error) int { - count := 0 - for _, err := range values { - if err == nil { - count++ - } - } - return count -} - -func countMatchingErrors(values []error, target error) int { - count := 0 - for _, err := range values { - if errors.Is(err, target) { - count++ - } - } - return count -} diff --git a/backend/internal/logic/inbox/service.go b/backend/internal/logic/inbox/service.go index 50403da..6c1688d 100644 --- a/backend/internal/logic/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -59,8 +59,8 @@ func (a StaticAnalyzer) Analyze(item models.SaInboxItem, userID uint) ([]Suggest // 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider,也不创建正式对象。 return []Suggestion{ {Kind: "task", Title: "跟进:" + subject, Body: body}, - {Kind: "note", Title: subject + "整理笔记", Body: body}, - {Kind: "source", Title: subject + "背景资料", Body: body}, + {Kind: "document", Title: subject + "整理笔记.md", Body: body}, + {Kind: "document", Title: subject + "背景资料.md", Body: body}, }, nil } @@ -222,7 +222,7 @@ func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) { for _, suggestion := range suggestions { kind := strings.ToLower(strings.TrimSpace(suggestion.Kind)) title := strings.TrimSpace(suggestion.Title) - if title == "" || (kind != "task" && kind != "note" && kind != "source") { + if title == "" || (kind != "task" && kind != "document") { return nil, ErrInvalidSuggestion } result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)}) @@ -274,17 +274,21 @@ func createConfirmedObject(tx *gorm.DB, item models.SaInboxItem, suggestion mode ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open", }).Error - case "note": - return tx.Create(&models.SaNote{ + case "document": + name := strings.TrimSpace(suggestion.Title) + if !strings.HasSuffix(strings.ToLower(name), ".md") { + name += ".md" + } + document := models.SaDocument{ ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, - SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body, - }).Error - case "source": - // 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。 - return tx.Create(&models.SaSource{ - ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, - SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body, - }).Error + SourceInboxItemID: &sourceInboxItemID, Kind: models.DocumentKindFile, Name: name, + NormalizedName: strings.ToLower(name), Extension: ".md", MimeType: "text/markdown; charset=utf-8", + Size: int64(len([]byte(suggestion.Body))), Revision: 1, ScanStatus: "not_scanned", + } + if err := tx.Create(&document).Error; err != nil { + return err + } + return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: suggestion.Body}).Error default: return ErrInvalidSuggestion } diff --git a/backend/internal/logic/inbox/service_test.go b/backend/internal/logic/inbox/service_test.go index c6552e7..22e6de6 100644 --- a/backend/internal/logic/inbox/service_test.go +++ b/backend/internal/logic/inbox/service_test.go @@ -44,7 +44,7 @@ func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) { require.NoError(t, err) require.Len(t, suggestions, 3) - require.Equal(t, []string{"task", "note", "source"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind}) + require.Equal(t, []string{"task", "document", "document"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind}) for _, suggestion := range suggestions { require.Contains(t, suggestion.Title, "客户访谈") require.Equal(t, item.Body, suggestion.Body) @@ -126,8 +126,8 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing. item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, - {Kind: "note", Title: "会议纪要", Body: "保留讨论结论"}, - {Kind: "source", Title: "背景资料", Body: "这是一段收集内容,不是上传文件"}, + {Kind: "document", Title: "会议纪要.md", Body: "保留讨论结论"}, + {Kind: "document", Title: "背景资料.md", Body: "这是一段收集内容,不是上传文件"}, {Kind: "task", Title: "不创建的任务", Body: "未被勾选"}, }}) analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) @@ -155,22 +155,19 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing. require.Equal(t, fixture.owner.ID, task.CreatedBy) requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity) - var note models.SaNote - require.NoError(t, fixture.database.First(¬e).Error) - require.Equal(t, "会议纪要", note.Title) - require.Equal(t, fixture.project.ID, note.ProjectID) - require.Equal(t, fixture.owner.ID, note.CreatedBy) - requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity) - - var source models.SaSource - require.NoError(t, fixture.database.First(&source).Error) - require.Equal(t, "背景资料", source.Title) - require.Equal(t, "text", source.Kind) - require.Empty(t, source.FilePath) - require.Equal(t, "这是一段收集内容,不是上传文件", source.ContentText) - require.Equal(t, fixture.project.ID, source.ProjectID) - require.Equal(t, fixture.owner.ID, source.CreatedBy) - requireSourceInboxIdentity(t, item, source.SourceInboxItemID, source.SourceInboxItemIdentity) + var documents []models.SaDocument + require.NoError(t, fixture.database.Order("id asc").Find(&documents).Error) + require.Len(t, documents, 2) + require.Equal(t, "会议纪要.md", documents[0].Name) + require.Equal(t, "背景资料.md", documents[1].Name) + for _, document := range documents { + require.Equal(t, fixture.project.ID, document.ProjectID) + require.Equal(t, fixture.owner.ID, document.CreatedBy) + requireSourceInboxIdentity(t, item, document.SourceInboxItemID, document.SourceInboxItemIdentity) + } + var secondContent models.SaDocumentContent + require.NoError(t, fixture.database.Where("document_id = ?", documents[1].ID).First(&secondContent).Error) + require.Equal(t, "这是一段收集内容,不是上传文件", secondContent.Markdown) var skipped int64 require.NoError(t, fixture.database.Model(&models.SaTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error) @@ -227,14 +224,15 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) { item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "事务任务", Body: "必须随失败回滚"}, - {Kind: "source", Title: "失败资料", Body: "模拟持久化失败"}, + {Kind: "document", Title: "失败资料.md", Body: "模拟持久化失败"}, }}) analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) require.Len(t, analysis.Suggestions, 2) - const callbackName = "test:fail_inbox_source_create" + const callbackName = "test:fail_inbox_document_create" require.NoError(t, fixture.database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { - if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SaSource" { + document, ok := tx.Statement.Dest.(*models.SaDocument) + if ok && document.Name == "失败资料.md" { tx.AddError(errors.New("simulated source write failure")) } })) @@ -256,8 +254,8 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ {Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"}, - {Kind: "note", Title: "确认结果", Body: "原始确认创建两个对象"}, - {Kind: "source", Title: "不能追加创建", Body: "已确认后忽略不同建议"}, + {Kind: "document", Title: "确认结果.md", Body: "原始确认创建两个对象"}, + {Kind: "document", Title: "不能追加创建.md", Body: "已确认后忽略不同建议"}, }}) analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}} @@ -268,11 +266,12 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi requireFormalObjectCounts(t, fixture.database, 1, 1, 0) sourceInboxItemID := item.ID - extraSource := models.SaSource{ + extraDocument := models.SaDocument{ ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID, - Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果", + Kind: models.DocumentKindFile, Name: "后续关联资料.md", NormalizedName: "后续关联资料.md", + Extension: ".md", Revision: 1, } - require.NoError(t, fixture.database.Create(&extraSource).Error) + require.NoError(t, fixture.database.Create(&extraDocument).Error) second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) require.Equal(t, http.StatusOK, second.Code, second.Body.String()) requireConfirmCreatedCount(t, second, 2) @@ -280,7 +279,7 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi var createdTask models.SaTask require.NoError(t, fixture.database.Where("source_inbox_item_id = ?", item.ID).First(&createdTask).Error) require.NoError(t, fixture.database.Delete(&createdTask).Error) - require.NoError(t, fixture.database.Delete(&extraSource).Error) + require.NoError(t, fixture.database.Delete(&extraDocument).Error) differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ "suggestionIds": []string{analysis.Suggestions[2].ID}, }) @@ -349,8 +348,7 @@ func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, so want int64 }{ {model: &models.SaTask{}, want: tasks}, - {model: &models.SaNote{}, want: notes}, - {model: &models.SaSource{}, want: sources}, + {model: &models.SaDocument{}, want: notes + sources}, } { var count int64 require.NoError(t, database.Model(check.model).Count(&count).Error) diff --git a/backend/internal/logic/notes/service.go b/backend/internal/logic/notes/service.go deleted file mode 100644 index 7899ae1..0000000 --- a/backend/internal/logic/notes/service.go +++ /dev/null @@ -1,24 +0,0 @@ -package notes - -import ( - "errors" - "strings" - - "senlinai-agent/backend/internal/models" -) - -type Service struct { -} - -func NewService() *Service { - return &Service{} -} - -func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*models.SaNote, error) { - title = strings.TrimSpace(title) - if title == "" { - return nil, errors.New("note title is required") - } - note := &models.SaNote{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown} - return note, models.DBService.Create(note).Error -} diff --git a/backend/internal/logic/projects/dto.go b/backend/internal/logic/projects/dto.go index fa23f00..0e91fad 100644 --- a/backend/internal/logic/projects/dto.go +++ b/backend/internal/logic/projects/dto.go @@ -50,7 +50,7 @@ type WorkspaceDTO struct { Inbox []WorkspaceInboxDTO `json:"inbox"` Tasks []WorkspaceTaskDTO `json:"tasks"` AISessions []WorkspaceAISessionDTO `json:"aiSessions"` - NotesSources []WorkspaceNoteSourceDTO `json:"notesSources"` + Documents []WorkspaceDocumentDTO `json:"documents"` CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"` } @@ -121,15 +121,15 @@ type WorkspaceAISessionDTO struct { References []string `json:"references"` } -// WorkspaceNoteSourceDTO 将笔记和资料合并为统一的工作区列表项。 -type WorkspaceNoteSourceDTO struct { +// WorkspaceDocumentDTO is the recent document summary used by the workspace. +type WorkspaceDocumentDTO struct { ID string `json:"id"` ProjectID string `json:"projectId"` Kind string `json:"kind"` - Title string `json:"title"` + Name string `json:"name"` + Extension string `json:"extension"` + MimeType string `json:"mimeType"` UpdatedAt time.Time `json:"updatedAt"` - Tag string `json:"tag"` - Source string `json:"source"` } // WorkspaceCronPlanDTO 是只展示元数据、不执行自主 Agent 的计划任务摘要。 diff --git a/backend/internal/logic/projects/service_test.go b/backend/internal/logic/projects/service_test.go index dbd1698..9d94a4f 100644 --- a/backend/internal/logic/projects/service_test.go +++ b/backend/internal/logic/projects/service_test.go @@ -127,8 +127,8 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) { require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error) require.NoError(t, database.Create(&models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error) - require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error) - require.NoError(t, database.Create(&models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error) + require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", Revision: 1}).Error) + require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", Revision: 1}).Error) require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error) require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error) require.NoError(t, database.Create(&models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly inbox review reminder", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, LastResult: "Not run yet"}).Error) @@ -158,9 +158,9 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) { require.NotEmpty(t, workspace.Tasks[0].CreatedAt) require.Len(t, workspace.AISessions, 1) require.Len(t, workspace.RecentSessions, 1) - require.Len(t, workspace.NotesSources, 2) - require.Equal(t, "note", workspace.NotesSources[0].Kind) - require.Equal(t, "link", workspace.NotesSources[1].Kind) + require.Len(t, workspace.Documents, 2) + require.Equal(t, models.DocumentKindFile, workspace.Documents[0].Kind) + require.Equal(t, models.DocumentKindFile, workspace.Documents[1].Kind) require.Len(t, workspace.CronPlans, 1) require.True(t, workspace.CronPlans[0].Enabled) require.Equal(t, "David", workspace.CronPlans[0].Owner) diff --git a/backend/internal/logic/projects/workspace.go b/backend/internal/logic/projects/workspace.go index 4755fce..a222bb0 100644 --- a/backend/internal/logic/projects/workspace.go +++ b/backend/internal/logic/projects/workspace.go @@ -13,7 +13,7 @@ type workspaceCounts struct { inbox int64 tasks int64 aiSessions int64 - notesSources int64 + documents int64 cronPlans int64 } @@ -48,7 +48,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, if err != nil { return WorkspaceDTO{}, err } - notesSources, err := s.workspaceNotesSources(project.ID, project.Identity) + documents, err := s.workspaceDocuments(project.ID, project.Identity) if err != nil { return WorkspaceDTO{}, err } @@ -74,7 +74,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO, Inbox: inboxItems, Tasks: tasks, AISessions: aiSessions, - NotesSources: notesSources, + Documents: documents, CronPlans: cronPlans, }, nil } @@ -90,15 +90,9 @@ func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) { if err := models.DBService.Model(&models.SaAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil { return counts, err } - var noteCount int64 - if err := models.DBService.Model(&models.SaNote{}).Where("project_id = ?", projectID).Count(¬eCount).Error; err != nil { + if err := models.DBService.Model(&models.SaDocument{}).Where("project_id = ?", projectID).Count(&counts.documents).Error; err != nil { return counts, err } - var sourceCount int64 - if err := models.DBService.Model(&models.SaSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil { - return counts, err - } - counts.notesSources = noteCount + sourceCount if err := models.DBService.Model(&models.SaCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil { return counts, err } @@ -118,13 +112,13 @@ func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) { } func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) { - total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans + total := counts.inbox + counts.tasks + counts.aiSessions + counts.documents + counts.cronPlans channels := []WorkspaceChannelDTO{ {ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1}, {ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2}, {ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3}, {ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4}, - {ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: counts.notesSources, URL: "", SortOrder: 5}, + {ID: projectIdentity + ":documents", ProjectID: projectIdentity, Type: "documents", Title: "Documents", Icon: "file", Count: counts.documents, URL: "", SortOrder: 5}, {ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6}, } var customChannels []models.SaProjectChannel @@ -229,27 +223,16 @@ func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([ return items, nil } -func (s *Service) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) { - var notes []models.SaNote - if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(¬es).Error; err != nil { +func (s *Service) workspaceDocuments(projectID uint, projectIdentity string) ([]WorkspaceDocumentDTO, error) { + var documents []models.SaDocument + if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&documents).Error; err != nil { return nil, err } - items := make([]WorkspaceNoteSourceDTO, 0, len(notes)) - for _, note := range notes { - items = append(items, WorkspaceNoteSourceDTO{ - ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title, - UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown", - }) - } - - var sources []models.SaSource - if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil { - return nil, err - } - for _, source := range sources { - items = append(items, WorkspaceNoteSourceDTO{ - ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title, - UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source), + items := make([]WorkspaceDocumentDTO, 0, len(documents)) + for _, document := range documents { + items = append(items, WorkspaceDocumentDTO{ + ID: document.Identity, ProjectID: projectIdentity, Kind: document.Kind, Name: document.Name, + Extension: document.Extension, MimeType: document.MimeType, UpdatedAt: document.UpdatedAt.UTC(), }) } return items, nil @@ -283,17 +266,6 @@ func utcOptionalTime(value *time.Time) *time.Time { return &utc } -func sourceDisplayName(source models.SaSource) string { - switch source.Kind { - case "file": - return "Attachment" - case "link": - return "URL" - default: - return source.Kind - } -} - func userDisplayName(assigneeID *uint, createdBy uint) (string, error) { userID := createdBy if assigneeID != nil && *assigneeID != 0 { diff --git a/backend/internal/logic/projects/workspace_test.go b/backend/internal/logic/projects/workspace_test.go index 396a090..f5e26ed 100644 --- a/backend/internal/logic/projects/workspace_test.go +++ b/backend/internal/logic/projects/workspace_test.go @@ -33,11 +33,11 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) { inbox := models.SaInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn research into tasks.", Status: "open", CreatedAt: createdAt, UpdatedAt: updatedAt} task := models.SaTask{ProjectID: project.ID, CreatedBy: 1, TagID: &projectTag.ID, Title: "Confirm IA", Description: "Review channel layout.", Status: "done", DueAt: &nextRun, CreatedAt: createdAt, UpdatedAt: updatedAt} session := models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI critique", Context: "Discuss channel behavior.", CreatedAt: createdAt, UpdatedAt: updatedAt} - note := models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes", CreatedAt: createdAt, UpdatedAt: updatedAt} - source := models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com", CreatedAt: createdAt, UpdatedAt: updatedAt} + firstDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", MimeType: "text/markdown", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt} + secondDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", MimeType: "application/pdf", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt} channel := models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap", SortOrder: 7, CreatedAt: createdAt, UpdatedAt: updatedAt} plan := models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly review", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, CreatedAt: createdAt, UpdatedAt: updatedAt} - for _, record := range []any{&inbox, &task, &session, ¬e, &source, &channel, &plan} { + for _, record := range []any{&inbox, &task, &session, &firstDocument, &secondDocument, &channel, &plan} { require.NoError(t, database.Create(record).Error) } @@ -71,10 +71,10 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) { require.Equal(t, updatedAt.UTC(), workspace.AISessions[0].UpdatedAt) require.Equal(t, session.Identity, workspace.RecentSessions[0].ID) - require.Len(t, workspace.NotesSources, 2) - require.Equal(t, note.Identity, workspace.NotesSources[0].ID) - require.Equal(t, source.Identity, workspace.NotesSources[1].ID) - for _, item := range workspace.NotesSources { + require.Len(t, workspace.Documents, 2) + require.Equal(t, secondDocument.Identity, workspace.Documents[0].ID) + require.Equal(t, firstDocument.Identity, workspace.Documents[1].ID) + for _, item := range workspace.Documents { require.Equal(t, project.Identity, item.ProjectID) require.Equal(t, updatedAt.UTC(), item.UpdatedAt) } diff --git a/backend/internal/logic/search/handlers_test.go b/backend/internal/logic/search/handlers_test.go index 6746ffe..f8d3f84 100644 --- a/backend/internal/logic/search/handlers_test.go +++ b/backend/internal/logic/search/handlers_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" @@ -37,8 +38,7 @@ func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) { require.NoError(t, database.Create(&project).Error) task := models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Description: "检查签名", Status: "open"} require.NoError(t, database.Create(&task).Error) - note := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "接口说明", Markdown: "回调验签流程"} - require.NoError(t, database.Create(¬e).Error) + document := createSearchDocument(t, database, project.ID, owner.ID, "接口说明.md", "回调验签流程", "", time.Time{}) router := searchHandlerTestRouter(owner.ID) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E5%9B%9E%E8%B0%83", nil) req.Header.Set("Authorization", "Bearer test-token") @@ -65,8 +65,8 @@ func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) { require.Equal(t, project.Identity, byType["project"]["projectId"]) require.Equal(t, task.Identity, byType["task"]["id"]) require.Equal(t, project.Identity, byType["task"]["projectId"]) - require.Equal(t, note.Identity, byType["note"]["id"]) - require.Equal(t, project.Identity, byType["note"]["projectId"]) + require.Equal(t, document.Identity, byType["document"]["id"]) + require.Equal(t, project.Identity, byType["document"]["projectId"]) } func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) { @@ -79,20 +79,17 @@ func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) { require.NoError(t, database.Create(&assigned).Error) privateTask := models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有任务", Status: "open"} require.NoError(t, database.Create(&privateTask).Error) - sharedNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词共享笔记", Markdown: "已显式分享"} - require.NoError(t, database.Create(&sharedNote).Error) - privateNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有笔记", Markdown: "不得泄漏"} - require.NoError(t, database.Create(&privateNote).Error) + sharedDocument := createSearchDocument(t, database, project.ID, owner.ID, "检索词共享文档.md", "已显式分享", "", time.Time{}) + privateDocument := createSearchDocument(t, database, project.ID, owner.ID, "检索词私有文档.md", "不得泄漏", "", time.Time{}) otherProject := models.SaProject{OwnerID: owner.ID, Name: "其他项目", Identifier: "OTHER"} require.NoError(t, database.Create(&otherProject).Error) - crossProjectNote := models.SaNote{ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "检索词跨项目笔记", Markdown: "伪造分享也不得泄漏"} - require.NoError(t, database.Create(&crossProjectNote).Error) + crossProjectDocument := createSearchDocument(t, database, otherProject.ID, owner.ID, "检索词跨项目文档.md", "伪造分享也不得泄漏", "", time.Time{}) require.NoError(t, database.Create(&models.SaTaskShare{ - TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID, + TaskID: assigned.ID, ObjectType: "document", ObjectID: sharedDocument.ID, }).Error) // 即使历史数据绕过服务层写入跨项目分享,搜索也必须在读取边界再次校验项目一致性。 require.NoError(t, database.Create(&models.SaTaskShare{ - TaskID: assigned.ID, ObjectType: "note", ObjectID: crossProjectNote.ID, + TaskID: assigned.ID, ObjectType: "document", ObjectID: crossProjectDocument.ID, }).Error) router := searchHandlerTestRouter(viewer.ID) req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E6%A3%80%E7%B4%A2%E8%AF%8D", nil) @@ -111,13 +108,13 @@ func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) { } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) require.Len(t, payload.Items, 2) - require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{payload.Items[0].ID, payload.Items[1].ID}) + require.ElementsMatch(t, []string{assigned.Identity, sharedDocument.Identity}, []string{payload.Items[0].ID, payload.Items[1].ID}) for _, item := range payload.Items { require.Equal(t, project.Identity, item.ProjectID) - require.Contains(t, []string{"task", "note"}, item.Type) + require.Contains(t, []string{"task", "document"}, item.Type) require.NotEqual(t, privateTask.Identity, item.ID) - require.NotEqual(t, privateNote.Identity, item.ID) - require.NotEqual(t, crossProjectNote.Identity, item.ID) + require.NotEqual(t, privateDocument.Identity, item.ID) + require.NotEqual(t, crossProjectDocument.Identity, item.ID) require.NotEqual(t, project.Identity, item.ID) } } diff --git a/backend/internal/logic/search/service.go b/backend/internal/logic/search/service.go index 3d0ee9c..a3f39d0 100644 --- a/backend/internal/logic/search/service.go +++ b/backend/internal/logic/search/service.go @@ -92,31 +92,40 @@ func (s *Service) Search(userID uint, query string) ([]SearchResultDTO, error) { }) } - var notes []models.SaNote - noteFilter := fmt.Sprintf(`(sa_projects.owner_id = ? OR EXISTS ( + var documents []models.SaDocument + documentFilter := fmt.Sprintf(`(sa_projects.owner_id = ? OR EXISTS ( SELECT 1 FROM sa_task_shares JOIN sa_tasks ON sa_tasks.id = sa_task_shares.task_id - WHERE sa_task_shares.object_type = 'note' - AND sa_task_shares.object_id = sa_notes.id - AND sa_tasks.project_id = sa_notes.project_id + WHERE sa_task_shares.object_type = 'document' + AND sa_task_shares.object_id = sa_documents.id + AND sa_tasks.project_id = sa_documents.project_id AND sa_tasks.assignee_id = ? - )) AND (sa_notes.title %s ? ESCAPE '!' OR sa_notes.markdown %s ? ESCAPE '!')`, operator, operator) - if err := s.database().Select("sa_notes.*").Joins("JOIN sa_projects ON sa_projects.id = sa_notes.project_id"). - Where(noteFilter, userID, userID, like, like). - Order("sa_notes.updated_at DESC").Order("sa_notes.identity ASC"). + )) AND (sa_documents.name %s ? ESCAPE '!' OR EXISTS ( + SELECT 1 FROM sa_document_contents + WHERE sa_document_contents.document_id = sa_documents.id + AND sa_document_contents.markdown %s ? ESCAPE '!' + ))`, operator, operator) + if err := s.database().Select("sa_documents.*").Joins("JOIN sa_projects ON sa_projects.id = sa_documents.project_id"). + Where(documentFilter, userID, userID, like, like). + Order("sa_documents.updated_at DESC").Order("sa_documents.identity ASC"). Limit(maxSearchResults). - Find(¬es).Error; err != nil { + Find(&documents).Error; err != nil { return nil, err } - noteResults := make([]rankedSearchResult, 0, len(notes)) - for _, note := range notes { - noteResults = append(noteResults, rankedSearchResult{ - result: SearchResultDTO{Type: "note", ID: note.Identity, ProjectID: note.ProjectIdentity, Title: note.Title, Snippet: note.Markdown}, - updatedAt: note.UpdatedAt, + documentResults := make([]rankedSearchResult, 0, len(documents)) + for _, document := range documents { + var content models.SaDocumentContent + _ = s.database().Where("document_id = ?", document.ID).First(&content).Error + documentResults = append(documentResults, rankedSearchResult{ + result: SearchResultDTO{ + Type: "document", ID: document.Identity, ProjectID: document.ProjectIdentity, + Title: document.Name, Snippet: content.Markdown, + }, + updatedAt: document.UpdatedAt, }) } - return stableFairLimit(projectResults, taskResults, noteResults), nil + return stableFairLimit(projectResults, taskResults, documentResults), nil } func stableFairLimit(buckets ...[]rankedSearchResult) []SearchResultDTO { diff --git a/backend/internal/logic/search/service_postgres_test.go b/backend/internal/logic/search/service_postgres_test.go index 984b1e3..14f7896 100644 --- a/backend/internal/logic/search/service_postgres_test.go +++ b/backend/internal/logic/search/service_postgres_test.go @@ -21,7 +21,7 @@ func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) { project := models.SaProject{OwnerID: owner.ID, Name: "中文回调平台", Identifier: "PG-CALLBACK"} require.NoError(t, database.Create(&project).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Status: "open"}).Error) - require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "说明", Markdown: "回调验签流程"}).Error) + createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "说明.md"}, "回调验签流程") results, err := NewService(database).Search(owner.ID, "回调") @@ -38,16 +38,15 @@ func TestPostgresSearchKeepsAssignmentAndExplicitShareBoundaries(t *testing.T) { assigned := models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, AssigneeID: &viewer.ID, Title: "边界词已指派", Status: "open"} require.NoError(t, database.Create(&assigned).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有任务", Status: "open"}).Error) - sharedNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词共享笔记"} - require.NoError(t, database.Create(&sharedNote).Error) - require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有笔记"}).Error) - require.NoError(t, database.Create(&models.SaTaskShare{TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID}).Error) + sharedDocument := createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "边界词共享笔记.md"}, "") + createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "边界词私有笔记.md"}, "") + require.NoError(t, database.Create(&models.SaTaskShare{TaskID: assigned.ID, ObjectType: "document", ObjectID: sharedDocument.ID}).Error) results, err := NewService(database).Search(viewer.ID, "边界词") require.NoError(t, err) require.Len(t, results, 2) - require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{results[0].ID, results[1].ID}) + require.ElementsMatch(t, []string{assigned.Identity, sharedDocument.Identity}, []string{results[0].ID, results[1].ID}) } func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) { @@ -71,14 +70,11 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) { Status: "open", UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute), }).Error) - require.NoError(t, database.Create(&models.SaNote{ + createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{ Identity: fmt.Sprintf("00000000-0000-7003-8000-%012d", index), - ProjectID: project.ID, - CreatedBy: owner.ID, - Title: fmt.Sprintf("稳定排序笔记 %02d", index), - Markdown: strings.Repeat("森", 300) + "尾", + Name: fmt.Sprintf("稳定排序笔记 %02d.md", index), UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute), - }).Error) + }, strings.Repeat("森", 300)+"尾") } service := NewService(database) @@ -86,7 +82,7 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) { require.NoError(t, err) require.Len(t, results, maxSearchResults) require.Equal(t, - []string{"project", "task", "note", "project", "task", "note", "project", "task", "note"}, + []string{"project", "task", "document", "project", "task", "document", "project", "task", "document"}, resultTypes(results[:9]), ) require.Equal(t, "00000000-0000-7001-8000-000000000059", results[0].ID) @@ -96,17 +92,39 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) { for _, result := range results { counts[result.Type]++ } - require.Equal(t, map[string]int{"project": 17, "task": 17, "note": 16}, counts) + require.Equal(t, map[string]int{"project": 17, "task": 17, "document": 16}, counts) again, err := service.Search(owner.ID, "稳定排序") require.NoError(t, err) require.Equal(t, results, again) - noteResults, err := service.Search(owner.ID, "森") + documentResults, err := service.Search(owner.ID, "森") require.NoError(t, err) - require.NotEmpty(t, noteResults) - require.Len(t, []rune(noteResults[0].Snippet), 240) - require.NotContains(t, noteResults[0].Snippet, "尾") + require.NotEmpty(t, documentResults) + require.Len(t, []rune(documentResults[0].Snippet), 240) + require.NotContains(t, documentResults[0].Snippet, "尾") +} + +func createPostgresSearchDocument( + t *testing.T, + database *gorm.DB, + project models.SaProject, + ownerID uint, + document models.SaDocument, + markdown string, +) models.SaDocument { + t.Helper() + document.ProjectID = project.ID + document.ProjectIdentity = project.Identity + document.CreatedBy = ownerID + document.Kind = models.DocumentKindFile + document.Extension = ".md" + document.MimeType = "text/markdown; charset=utf-8" + document.NormalizedName = strings.ToLower(document.Name) + document.Revision = 1 + require.NoError(t, database.Create(&document).Error) + require.NoError(t, database.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error) + return document } func newPostgresSearchTestDB(t *testing.T) *gorm.DB { diff --git a/backend/internal/logic/search/service_test.go b/backend/internal/logic/search/service_test.go index b2e93f6..cc2ebaf 100644 --- a/backend/internal/logic/search/service_test.go +++ b/backend/internal/logic/search/service_test.go @@ -12,20 +12,20 @@ import ( "senlinai-agent/backend/internal/models" ) -func TestSearchFindsNoteBody(t *testing.T) { +func TestSearchFindsDocumentBody(t *testing.T) { database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database require.NoError(t, database.Create(&models.SaProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error) - require.NoError(t, database.Create(&models.SaNote{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error) + createSearchDocument(t, database, 1, 7, "接口方案.md", "二维码支付回调设计", "", time.Time{}) service := NewService() results, err := service.Search(7, "回调") require.NoError(t, err) require.Len(t, results, 1) - require.Equal(t, "note", results[0].Type) + require.Equal(t, "document", results[0].Type) } func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) { @@ -52,13 +52,18 @@ func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) { Status: "open", UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute), }).Error) - require.NoError(t, database.Create(&models.SaNote{ - Identity: fmt.Sprintf("00000000-0000-7003-8000-%012d", index), - ProjectID: project.ID, - CreatedBy: 7, - Title: fmt.Sprintf("稳定排序笔记 %02d", index), - UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute), - }).Error) + document := models.SaDocument{ + Identity: fmt.Sprintf("00000000-0000-7003-8000-%012d", index), + ProjectID: project.ID, + CreatedBy: 7, + Kind: models.DocumentKindFile, + Name: fmt.Sprintf("稳定排序文档 %02d.md", index), + NormalizedName: fmt.Sprintf("稳定排序文档 %02d.md", index), + Extension: ".md", + Revision: 1, + UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute), + } + require.NoError(t, database.Create(&document).Error) } service := NewService(database) @@ -70,9 +75,9 @@ func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) { for _, result := range results { counts[result.Type]++ } - require.Equal(t, map[string]int{"project": 17, "task": 17, "note": 16}, counts) + require.Equal(t, map[string]int{"project": 17, "task": 17, "document": 16}, counts) require.Equal(t, - []string{"project", "task", "note", "project", "task", "note", "project", "task", "note"}, + []string{"project", "task", "document", "project", "task", "document", "project", "task", "document"}, resultTypes(results[:9]), ) require.Equal(t, "00000000-0000-7001-8000-000000000059", results[0].ID) @@ -91,12 +96,7 @@ func TestSearchBoundsChineseSnippetsByRune(t *testing.T) { models.DBService = database project := models.SaProject{OwnerID: 7, Name: "摘要项目", Identifier: "SNIPPET"} require.NoError(t, database.Create(&project).Error) - require.NoError(t, database.Create(&models.SaNote{ - ProjectID: project.ID, - CreatedBy: 7, - Title: "中文摘要", - Markdown: strings.Repeat("森", 300) + "尾", - }).Error) + createSearchDocument(t, database, project.ID, 7, "中文摘要.md", strings.Repeat("森", 300)+"尾", "", time.Time{}) results, err := NewService(database).Search(7, "森") require.NoError(t, err) @@ -113,15 +113,14 @@ func resultTypes(results []SearchResultDTO) []string { return types } -func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) { +func TestSearchOnlyReturnsNavigableProjectTaskAndDocumentResults(t *testing.T) { database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) require.NoError(t, models.AutoMigrate(database)) models.DBService = database require.NoError(t, database.Create(&models.SaProject{ID: 1, OwnerID: 7, Name: "webhook 支付项目"}).Error) require.NoError(t, database.Create(&models.SaTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error) - require.NoError(t, database.Create(&models.SaNote{ProjectID: 1, CreatedBy: 7, Title: "回调笔记", Markdown: "webhook 流程"}).Error) - require.NoError(t, database.Create(&models.SaSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error) + createSearchDocument(t, database, 1, 7, "回调文档.md", "webhook 流程", "", time.Time{}) require.NoError(t, database.Create(&models.SaInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error) require.NoError(t, database.Create(&models.SaAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error) @@ -133,10 +132,23 @@ func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) { for _, result := range results { types[result.Type] = true } - require.Equal(t, map[string]bool{"project": true, "task": true, "note": true}, types) + require.Equal(t, map[string]bool{"project": true, "task": true, "document": true}, types) require.Len(t, results, 3) } +func createSearchDocument(t *testing.T, database *gorm.DB, projectID, userID uint, name, markdown, identity string, updatedAt time.Time) models.SaDocument { + t.Helper() + document := models.SaDocument{ + Identity: identity, ProjectID: projectID, CreatedBy: userID, Kind: models.DocumentKindFile, + Name: name, NormalizedName: strings.ToLower(name), Extension: ".md", + MimeType: "text/markdown", Size: int64(len([]byte(markdown))), Revision: 1, + UpdatedAt: updatedAt, + } + require.NoError(t, database.Create(&document).Error) + require.NoError(t, database.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error) + return document +} + func TestSearchTreatsLikeWildcardsAsLiteralKeywords(t *testing.T) { database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) require.NoError(t, err) diff --git a/backend/internal/logic/tasks/concurrency_postgres_test.go b/backend/internal/logic/tasks/concurrency_postgres_test.go index 950742f..682b98e 100644 --- a/backend/internal/logic/tasks/concurrency_postgres_test.go +++ b/backend/internal/logic/tasks/concurrency_postgres_test.go @@ -29,13 +29,13 @@ func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *tes require.NoError(t, database.Create(&first).Error) require.NoError(t, database.Create(&second).Error) task := models.SaTask{ProjectID: first.ID, CreatedBy: owner.ID, Title: "Move", Status: "open"} - note := models.SaNote{ProjectID: first.ID, CreatedBy: owner.ID, Title: "Old context", Markdown: "private"} + document := models.SaDocument{ProjectID: first.ID, CreatedBy: owner.ID, Kind: models.DocumentKindFile, Name: "Old context.md", NormalizedName: "old context.md", Extension: ".md", Revision: 1} require.NoError(t, database.Create(&task).Error) - require.NoError(t, database.Create(¬e).Error) + require.NoError(t, database.Create(&document).Error) t.Cleanup(func() { database.Where("task_id = ?", task.ID).Delete(&models.SaTaskShare{}) database.Where("entity_type = ? AND entity_id = ?", "task", task.ID).Delete(&models.SaProjectEvent{}) - database.Delete(¬e) + database.Delete(&document) database.Delete(&task) database.Delete(&first) database.Delete(&second) @@ -78,7 +78,7 @@ func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *tes } shareResult := make(chan error, 1) - go func() { shareResult <- service.ShareObject(task.ID, "note", note.ID) }() + go func() { shareResult <- service.ShareObject(task.ID, "document", document.ID) }() select { case err := <-shareResult: t.Fatalf("ShareObject returned before the moving transaction released its task lock: %v", err) diff --git a/backend/internal/logic/tasks/handlers_test.go b/backend/internal/logic/tasks/handlers_test.go index e132d61..857fcc4 100644 --- a/backend/internal/logic/tasks/handlers_test.go +++ b/backend/internal/logic/tasks/handlers_test.go @@ -50,9 +50,9 @@ func TestTaskRegistrarMovesTaskByIdentityAndClearsForeignProjectTag(t *testing.T require.NoError(t, database.Create(&tag).Error) task := models.SaTask{ProjectID: first.ID, CreatedBy: 1, TagID: &tag.ID, Title: "迁移任务", Status: "open"} require.NoError(t, database.Create(&task).Error) - note := models.SaNote{ProjectID: first.ID, CreatedBy: 1, Title: "旧项目资料", Markdown: "仅可在 Alpha 分享"} - require.NoError(t, database.Create(¬e).Error) - require.NoError(t, NewService(database).ShareObject(task.ID, "note", note.ID)) + document := models.SaDocument{ProjectID: first.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "旧项目资料.md", NormalizedName: "旧项目资料.md", Extension: ".md", Revision: 1} + require.NoError(t, database.Create(&document).Error) + require.NoError(t, NewService(database).ShareObject(task.ID, "document", document.ID)) body := bytes.NewBufferString(fmt.Sprintf(`{"title":"迁移任务","description":"已移动","completed":false,"nextProjectId":%q}`, strings.ToUpper(second.Identity))) req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+first.Identity+"/tasks/"+task.Identity, body) req.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/logic/tasks/service.go b/backend/internal/logic/tasks/service.go index a935104..3135a9c 100644 --- a/backend/internal/logic/tasks/service.go +++ b/backend/internal/logic/tasks/service.go @@ -63,9 +63,9 @@ func (s *Service) Assign(taskID uint, assigneeIdentity string) error { }) } -// ShareObject 只允许显式分享 note/source,并在同一事务中验证关联对象属于任务所在项目。 +// ShareObject only allows an explicitly shared document from the task project. func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error { - if objectType != "note" && objectType != "source" { + if objectType != "document" { return errors.New("unsupported shared object type") } return s.database().Transaction(func(tx *gorm.DB) error { @@ -73,7 +73,7 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err if err != nil { return err } - if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil { + if err := ensureSharedObjectInProject(tx, task.ProjectID, objectID); err != nil { return err } if err := tx.Create(&models.SaTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil { @@ -193,7 +193,7 @@ func (s *Service) Update(ownerID uint, projectIdentity, taskIdentity string, inp task.TagID = tagID task.TagIdentity = tagIdentity if targetProject.ID != currentProject.ID { - // 现有分享都在原项目边界内;移动后必须清空,避免旧项目 note/source 继续对被指派人可见。 + // 现有分享都在原项目边界内;移动后必须清空,避免旧项目 document 继续对被指派人可见。 if err := tx.Where("task_id = ?", task.ID).Delete(&models.SaTaskShare{}).Error; err != nil { return err } @@ -267,24 +267,13 @@ func utcOptionalTime(value *time.Time) *time.Time { return &result } -func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error { - switch objectType { - case "note": - var count int64 - if err := tx.Model(&models.SaNote{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return errors.New("shared object not found in task project") - } - case "source": - var count int64 - if err := tx.Model(&models.SaSource{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return errors.New("shared object not found in task project") - } +func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectID uint) error { + var count int64 + if err := tx.Model(&models.SaDocument{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return errors.New("shared object not found in task project") } return nil } diff --git a/backend/internal/logic/tasks/service_test.go b/backend/internal/logic/tasks/service_test.go index d46113e..535bdac 100644 --- a/backend/internal/logic/tasks/service_test.go +++ b/backend/internal/logic/tasks/service_test.go @@ -24,11 +24,11 @@ func TestUpdateLocksTaskBeforeProjectValidation(t *testing.T) { func TestShareObjectLocksTaskBeforeObjectValidation(t *testing.T) { database, _, project, task := newTaskLockFixture(t) - note := models.SaNote{ProjectID: project.ID, CreatedBy: task.CreatedBy, Title: "Context", Markdown: "Private"} - require.NoError(t, database.Create(¬e).Error) + document := models.SaDocument{ProjectID: project.ID, CreatedBy: task.CreatedBy, Kind: models.DocumentKindFile, Name: "Context.md", NormalizedName: "context.md", Extension: ".md", Revision: 1} + require.NoError(t, database.Create(&document).Error) queries := captureTaskQueryOrder(t, database) - err := NewService(database).ShareObject(task.ID, "note", note.ID) + err := NewService(database).ShareObject(task.ID, "document", document.ID) require.NoError(t, err) require.NotEmpty(t, *queries) @@ -39,21 +39,21 @@ func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) { database := newTestDB(t) assigneeID := uint(2) task := models.SaTask{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"} - note := models.SaNote{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"} + document := models.SaDocument{ProjectID: 1, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "合同背景.md", NormalizedName: "合同背景.md", Extension: ".md", Revision: 1} require.NoError(t, database.Create(&task).Error) - require.NoError(t, database.Create(¬e).Error) + require.NoError(t, database.Create(&document).Error) service := NewService() before, err := service.VisibleLinkedObjects(task.ID, assigneeID) require.NoError(t, err) require.Empty(t, before) - require.NoError(t, service.ShareObject(task.ID, "note", note.ID)) + require.NoError(t, service.ShareObject(task.ID, "document", document.ID)) after, err := service.VisibleLinkedObjects(task.ID, assigneeID) require.NoError(t, err) require.Len(t, after, 1) - require.Equal(t, "note", after[0].ObjectType) - require.Equal(t, note.Identity, after[0].ObjectID) + require.Equal(t, "document", after[0].ObjectType) + require.Equal(t, document.Identity, after[0].ObjectID) } func TestShareObjectRejectsUnsupportedType(t *testing.T) { @@ -68,12 +68,12 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) { func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) { database := newTestDB(t) task := models.SaTask{ProjectID: 1, CreatedBy: 1, Title: "Review"} - note := models.SaNote{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"} + document := models.SaDocument{ProjectID: 2, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Other project.md", NormalizedName: "other project.md", Extension: ".md", Revision: 1} require.NoError(t, database.Create(&task).Error) - require.NoError(t, database.Create(¬e).Error) + require.NoError(t, database.Create(&document).Error) service := NewService() - err := service.ShareObject(task.ID, "note", note.ID) + err := service.ShareObject(task.ID, "document", document.ID) require.ErrorContains(t, err, "shared object not found in task project") } diff --git a/backend/internal/models/document.go b/backend/internal/models/document.go new file mode 100644 index 0000000..043b1dc --- /dev/null +++ b/backend/internal/models/document.go @@ -0,0 +1,85 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + DocumentKindFolder = "folder" + DocumentKindFile = "file" +) + +type SaDocument struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + ProjectID uint `gorm:"index;not null;uniqueIndex:uidx_sa_document_sibling,priority:1"` + ProjectIdentity string `gorm:"type:char(36);index"` + ParentID *uint `gorm:"index"` + ParentIdentity *string `gorm:"type:char(36);index"` + ParentScope string `gorm:"type:char(36);not null;default:'';uniqueIndex:uidx_sa_document_sibling,priority:2"` + CreatedBy uint `gorm:"index;not null"` + CreatedByIdentity string `gorm:"type:char(36);index"` + SourceInboxItemID *uint `gorm:"index"` + SourceInboxItemIdentity *string `gorm:"type:char(36);index"` + Kind string `gorm:"size:16;not null"` + Name string `gorm:"not null"` + NormalizedName string `gorm:"not null;uniqueIndex:uidx_sa_document_sibling,priority:3"` + Extension string `gorm:"size:32"` + MimeType string `gorm:"size:255"` + Size int64 `gorm:"not null;default:0"` + Revision uint64 `gorm:"not null;default:1"` + SortOrder int `gorm:"not null;default:0"` + ScanStatus string `gorm:"size:24;not null;default:'not_scanned'"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (SaDocument) TableName() string { + return "sa_documents" +} + +type SaDocumentContent struct { + ID uint `gorm:"primaryKey"` + DocumentID uint `gorm:"uniqueIndex;not null"` + Markdown string `gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SaDocumentContent) TableName() string { + return "sa_document_contents" +} + +type SaDocumentBlob struct { + ID uint `gorm:"primaryKey"` + DocumentID uint `gorm:"uniqueIndex;not null"` + StorageKey string `gorm:"size:64;uniqueIndex;not null"` + RelativePath string `gorm:"not null"` + OriginalName string `gorm:"not null"` + Checksum string `gorm:"size:64;not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (SaDocumentBlob) TableName() string { + return "sa_document_blobs" +} + +type SaDocumentShare struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + DocumentID uint `gorm:"index;not null"` + DocumentIdentity string `gorm:"type:char(36);index"` + CreatedBy uint `gorm:"index;not null"` + TokenHash string `gorm:"size:64;uniqueIndex;not null"` + ExpiresAt time.Time `gorm:"index;not null"` + RevokedAt *time.Time `gorm:"index"` + CreatedAt time.Time +} + +func (SaDocumentShare) TableName() string { + return "sa_document_shares" +} diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index efa291a..0b13aca 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -75,7 +75,7 @@ func (m *SaTask) BeforeCreate(tx *gorm.DB) error { return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity) } -func (m *SaNote) BeforeCreate(tx *gorm.DB) error { +func (m *SaDocument) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err } @@ -85,20 +85,20 @@ func (m *SaNote) BeforeCreate(tx *gorm.DB) error { if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil { return err } + if err := resolveOptionalIdentity(tx, &SaDocument{}, m.ParentID, &m.ParentIdentity); err != nil { + return err + } return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity) } -func (m *SaSource) BeforeCreate(tx *gorm.DB) error { +func (m *SaDocumentShare) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err } - if err := resolveIdentity(tx, &SaProject{}, m.ProjectID, &m.ProjectIdentity); err != nil { + if err := resolveIdentity(tx, &SaDocument{}, m.DocumentID, &m.DocumentIdentity); err != nil { return err } - if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil { - return err - } - return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity) + return nil } func (m *SaAISession) BeforeCreate(tx *gorm.DB) error { @@ -233,10 +233,8 @@ func resolveEntityIdentity(tx *gorm.DB, entityType string, entityID uint, identi return resolveIdentity(tx, &SaInboxItem{}, entityID, identity) case "task": return resolveIdentity(tx, &SaTask{}, entityID, identity) - case "note": - return resolveIdentity(tx, &SaNote{}, entityID, identity) - case "source": - return resolveIdentity(tx, &SaSource{}, entityID, identity) + case "document": + return resolveIdentity(tx, &SaDocument{}, entityID, identity) case "ai_session": return resolveIdentity(tx, &SaAISession{}, entityID, identity) case "tag": diff --git a/backend/internal/models/migrations.go b/backend/internal/models/migrations.go index 52a4033..c78035e 100644 --- a/backend/internal/models/migrations.go +++ b/backend/internal/models/migrations.go @@ -31,6 +31,7 @@ func runVersionedMigrations(database *gorm.DB) error { migrations := []versionedMigration{ {version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers}, {version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags}, + {version: 3, name: "replace_notes_sources_with_documents", run: replaceNotesSourcesWithDocuments}, } for _, migration := range migrations { // Connection callbacks can provide an initialized Gorm session. Start each @@ -63,6 +64,28 @@ func runVersionedMigrations(database *gorm.DB) error { return nil } +func replaceNotesSourcesWithDocuments(tx *gorm.DB) (map[string]int, error) { + deletedShares := int64(0) + if tx.Migrator().HasTable(&SaTaskShare{}) { + result := tx.Where("object_type IN ?", []string{"note", "source"}).Delete(&SaTaskShare{}) + if result.Error != nil { + return nil, result.Error + } + deletedShares = result.RowsAffected + } + dropped := 0 + for _, table := range []string{"sa_notes", "sa_sources"} { + if !tx.Migrator().HasTable(table) { + continue + } + if err := tx.Migrator().DropTable(table); err != nil { + return nil, err + } + dropped++ + } + return map[string]int{"dropped_tables": dropped, "deleted_task_shares": int(deletedShares)}, nil +} + func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) { var projects []SaProject if err := tx.Model(&SaProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil { diff --git a/backend/internal/models/migrations_test.go b/backend/internal/models/migrations_test.go index 7ca323f..b584a8b 100644 --- a/backend/internal/models/migrations_test.go +++ b/backend/internal/models/migrations_test.go @@ -63,14 +63,14 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te var migrationCount int64 require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error) - require.Equal(t, int64(2), migrationCount) + require.Equal(t, int64(3), migrationCount) var auditDetails []string require.NoError(t, database.Table("sa_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error) require.Contains(t, auditDetails[0], `"updated":3`) require.Contains(t, auditDetails[1], `"deduplicated":1`) require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again") require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error) - require.Equal(t, int64(2), migrationCount) + require.Equal(t, int64(3), migrationCount) require.Error(t, database.Exec(`INSERT INTO sa_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error) require.Error(t, database.Exec(`INSERT INTO sa_tags (project_id, name) VALUES (1, 'UI')`).Error) diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 0042f5a..0c224cd 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -36,8 +36,10 @@ func AutoMigrate(database *gorm.DB) error { &SaInboxItem{}, &SaInboxSuggestion{}, &SaTask{}, - &SaNote{}, - &SaSource{}, + &SaDocument{}, + &SaDocumentContent{}, + &SaDocumentBlob{}, + &SaDocumentShare{}, &SaAIExpertCategory{}, &SaAIExpertItem{}, &SaAISession{}, diff --git a/backend/internal/models/note.go b/backend/internal/models/note.go deleted file mode 100644 index 9bfe65a..0000000 --- a/backend/internal/models/note.go +++ /dev/null @@ -1,22 +0,0 @@ -package models - -import "time" - -type SaNote struct { - ID uint `gorm:"primaryKey"` - Identity string `gorm:"type:char(36);uniqueIndex"` - ProjectID uint `gorm:"index;not null"` - ProjectIdentity string `gorm:"type:char(36);index"` - CreatedBy uint `gorm:"index;not null"` - CreatedByIdentity string `gorm:"type:char(36);index"` - SourceInboxItemID *uint `gorm:"index"` - SourceInboxItemIdentity *string `gorm:"type:char(36);index"` - Title string `gorm:"not null"` - Markdown string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (SaNote) TableName() string { - return "sa_notes" -} diff --git a/backend/internal/models/source.go b/backend/internal/models/source.go deleted file mode 100644 index 8d9eeae..0000000 --- a/backend/internal/models/source.go +++ /dev/null @@ -1,25 +0,0 @@ -package models - -import "time" - -type SaSource struct { - ID uint `gorm:"primaryKey"` - Identity string `gorm:"type:char(36);uniqueIndex"` - ProjectID uint `gorm:"index;not null"` - ProjectIdentity string `gorm:"type:char(36);index"` - CreatedBy uint `gorm:"index;not null"` - CreatedByIdentity string `gorm:"type:char(36);index"` - SourceInboxItemID *uint `gorm:"index"` - SourceInboxItemIdentity *string `gorm:"type:char(36);index"` - Kind string `gorm:"not null"` - Title string `gorm:"not null"` - URL string - FilePath string - ContentText string `gorm:"type:text"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (SaSource) TableName() string { - return "sa_sources" -} diff --git a/backend/internal/seed/demo.go b/backend/internal/seed/demo.go index c9ec9f0..5f65e38 100644 --- a/backend/internal/seed/demo.go +++ b/backend/internal/seed/demo.go @@ -174,19 +174,14 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error { return err } } - for _, note := range []models.SaNote{ - {ProjectID: projectID, CreatedBy: userID, Title: "用户权限体系设计文档 v2.1", Markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"}, - {ProjectID: projectID, CreatedBy: userID, Title: "导出功能技术方案评审", Markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"}, + for _, document := range []struct { + name string + markdown string + }{ + {name: "用户权限体系设计文档 v2.1.md", markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"}, + {name: "导出功能技术方案评审.md", markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"}, } { - if err := firstOrCreateNote(tx, note); err != nil { - return err - } - } - for _, source := range []models.SaSource{ - {ProjectID: projectID, CreatedBy: userID, Kind: "file", Title: "2026 Q3 竞品功能对比表", FilePath: "projects/a1/competitors.xlsx", ContentText: "竞品功能矩阵和价格摘要"}, - {ProjectID: projectID, CreatedBy: userID, Kind: "link", Title: "客户访谈记录索引", URL: "https://example.com/interviews", ContentText: "客户访谈和问题归类"}, - } { - if err := firstOrCreateSource(tx, source); err != nil { + if err := firstOrCreateDocument(tx, projectID, userID, document.name, document.markdown); err != nil { return err } } @@ -261,12 +256,25 @@ func firstOrCreateAISession(tx *gorm.DB, session models.SaAISession) error { return firstOrCreate(tx, &models.SaAISession{}, "project_id = ? AND title = ?", []any{session.ProjectID, session.Title}, session) } -func firstOrCreateNote(tx *gorm.DB, note models.SaNote) error { - return firstOrCreate(tx, &models.SaNote{}, "project_id = ? AND title = ?", []any{note.ProjectID, note.Title}, note) -} - -func firstOrCreateSource(tx *gorm.DB, source models.SaSource) error { - return firstOrCreate(tx, &models.SaSource{}, "project_id = ? AND title = ?", []any{source.ProjectID, source.Title}, source) +func firstOrCreateDocument(tx *gorm.DB, projectID, userID uint, name, markdown string) error { + var document models.SaDocument + err := tx.Where("project_id = ? AND normalized_name = ?", projectID, strings.ToLower(name)).First(&document).Error + if err == nil { + return nil + } + if err != gorm.ErrRecordNotFound { + return err + } + document = models.SaDocument{ + ProjectID: projectID, CreatedBy: userID, Kind: models.DocumentKindFile, + Name: name, NormalizedName: strings.ToLower(name), Extension: ".md", + MimeType: "text/markdown; charset=utf-8", Size: int64(len([]byte(markdown))), + Revision: 1, ScanStatus: "not_scanned", + } + if err := tx.Create(&document).Error; err != nil { + return err + } + return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error } func firstOrCreateCronPlan(tx *gorm.DB, plan models.SaCronPlan) error { diff --git a/backend/internal/seed/demo_postgres_test.go b/backend/internal/seed/demo_postgres_test.go index ccb7388..4042068 100644 --- a/backend/internal/seed/demo_postgres_test.go +++ b/backend/internal/seed/demo_postgres_test.go @@ -147,8 +147,7 @@ func TestPostgresDemoSeedSerializesConcurrentRunsForSameOwner(t *testing.T) { assertPostgresDemoCount(t, database, &models.SaTask{}, "project_id IN ?", []any{projectIDs}, 6) assertPostgresDemoCount(t, database, &models.SaInboxItem{}, "project_id IN ?", []any{projectIDs}, 7) assertPostgresDemoCount(t, database, &models.SaAISession{}, "project_id = ?", []any{projects[0].ID}, 4) - assertPostgresDemoCount(t, database, &models.SaNote{}, "project_id = ?", []any{projects[0].ID}, 2) - assertPostgresDemoCount(t, database, &models.SaSource{}, "project_id = ?", []any{projects[0].ID}, 2) + assertPostgresDemoCount(t, database, &models.SaDocument{}, "project_id = ?", []any{projects[0].ID}, 2) assertPostgresDemoCount(t, database, &models.SaCronPlan{}, "project_id = ?", []any{projects[0].ID}, 3) assertPostgresDemoCount(t, database, &models.SaProjectChannel{}, "project_id = ?", []any{projects[0].ID}, 2) } @@ -217,8 +216,7 @@ func cleanupPostgresDemoSeed(database *gorm.DB, userID uint) { } database.Where("project_id IN ?", projectIDs).Delete(&models.SaProjectEvent{}) database.Where("project_id IN ?", projectIDs).Delete(&models.SaTask{}) - database.Where("project_id IN ?", projectIDs).Delete(&models.SaNote{}) - database.Where("project_id IN ?", projectIDs).Delete(&models.SaSource{}) + database.Where("project_id IN ?", projectIDs).Delete(&models.SaDocument{}) database.Where("project_id IN ?", projectIDs).Delete(&models.SaAISession{}) database.Where("project_id IN ?", projectIDs).Delete(&models.SaTag{}) database.Where("project_id IN ?", projectIDs).Delete(&models.SaProjectChannel{}) diff --git a/backend/internal/seed/demo_test.go b/backend/internal/seed/demo_test.go index 61e985f..1310faf 100644 --- a/backend/internal/seed/demo_test.go +++ b/backend/internal/seed/demo_test.go @@ -35,7 +35,7 @@ func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) { require.GreaterOrEqual(t, len(workspace.Inbox), 4) require.GreaterOrEqual(t, len(workspace.Tasks), 3) require.GreaterOrEqual(t, len(workspace.AISessions), 4) - require.GreaterOrEqual(t, len(workspace.NotesSources), 3) + require.GreaterOrEqual(t, len(workspace.Documents), 2) require.GreaterOrEqual(t, len(workspace.CronPlans), 3) for _, plan := range workspace.CronPlans { require.Empty(t, plan.LastResult, "演示数据不得声称计划已执行") diff --git a/docs/superpowers/plans/2026-07-24-project-documents-workbench.md b/docs/superpowers/plans/2026-07-24-project-documents-workbench.md new file mode 100644 index 0000000..1509e84 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-project-documents-workbench.md @@ -0,0 +1,14 @@ +# 项目 Documents 工作台实施计划 + +1. 新建 document 模型和版本化破坏性迁移,删除旧 note/source 模型注册。 +2. 新建聚焦的 `internal/logic/documents` 服务与 HTTP handler,实现树、内容、上传、移动、 + 删除、下载、分享和导出。 +3. 将工作台聚合、搜索、Inbox、探索沉淀、任务关联和 seed 切换为 document。 +4. 新建前端 documents API、类型与跨频道 intent。 +5. 使用 CodeMirror 6 实现全画布文件树、多标签编辑器、安全 Markdown 预览和标签持久化。 +6. 实现 PDF、图片、文本、音视频、DOCX、XLS/XLSX 预览适配器。 +7. 实现三并发上传队列、50 MB 校验、正文拖入根目录和文件树底部进度面板。 +8. 实现 Markdown/PDF/DOCX 导出和匿名限时分享。 +9. 增加后端服务/handler 测试与前端 API/交互检查。 +10. 运行 `go test ./...`、`node scripts/visual-check.mjs`、`npm run build` 和 `npm run lint`, + 修复后创建一个聚焦提交。 diff --git a/docs/superpowers/specs/2026-07-24-project-documents-workbench-design.md b/docs/superpowers/specs/2026-07-24-project-documents-workbench-design.md new file mode 100644 index 0000000..633000e --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-project-documents-workbench-design.md @@ -0,0 +1,101 @@ +# 项目笔记资料(Documents)工作台设计 + +## 目标 + +将项目内原有的笔记与资料能力破坏性重构为统一的 `document` 领域。技术命名只使用 +`document/documents`,中文产品名称继续使用“笔记资料”。 + +本次不迁移旧 `SaNote`、`SaSource` 数据,也不保留旧 API。 + +## 数据模型 + +- `SaDocument`:项目内树节点,支持 `folder` 与 `file`,保存父节点、名称、MIME、扩展名、 + 大小、修订号、来源 Inbox 和软删除状态。 +- `SaDocumentContent`:Markdown 可编辑正文,与文件节点一对一。 +- `SaDocumentBlob`:二进制文件存储元数据,与文件节点一对一;物理路径只能由 document + 文件服务构造。 +- `SaDocumentShare`:匿名只读预览链接,数据库只保存随机 token 的哈希、过期时间和撤销时间。 + +目录内名称忽略大小写唯一。手工重命名遇到重名返回冲突;新建与上传自动追加 `(1)`。 + +## 后端契约 + +API 根路径为 `/api/v1/projects/:projectId/documents`,提供: + +- 获取项目文档树; +- 新建目录与 Markdown; +- 上传单个文件(单文件 50 MB); +- 读取文档详情和内容; +- 使用 `revision` 更新 Markdown,冲突时拒绝覆盖; +- 重命名、移动与软删除; +- 下载原文件; +- 将 Markdown 导出为 `.md`、`.pdf`、`.docx`; +- 创建、列出和撤销有效期分享链接。 + +匿名分享 API 只暴露单个文档的只读预览,不暴露项目、目录树或创建人,不允许下载。 +无效、过期与撤销 token 均返回 404。 + +旧表和旧任务关联在版本化迁移中直接删除,不做数据转换。 + +## 页面布局 + +保留工作台顶部栏、项目 rail、项目 sidebar 与状态栏。`documents` 频道内容区采用无标题、 +无副标题、无卡片边距的全画布布局: + +- 左侧:可调整宽度、可折叠的文件树;顶部只有新建目录和新建 Markdown 图标; +- 右侧:VS Code 风格横向多标签区与主编辑/预览区; +- 标签栏末尾固定编辑/预览、保存、导出和分享操作; +- 文件树底部显示可展开的上传队列和汇总进度。 + +目录优先并按名称自然排序。文件和目录可拖动移动,禁止目录移入自身或后代。正文侧拖入 +的文件一律上传至项目根目录。 + +## 编辑、标签与自动保存 + +- Markdown 使用 CodeMirror 6; +- Markdown 编辑与预览采用单面切换; +- 没有标签时显示本地 `新建文件1.md`,首次输入或手工保存后才创建正式文档; +- 停止输入 800ms 后自动保存; +- 标签展示未保存、保存中、已保存和失败状态; +- 保存失败时保留本地草稿并重试; +- 标签按项目保存到浏览器;同一文档只打开一个标签; +- 服务端使用单调递增 `revision` 防止多窗口静默覆盖。 + +## 预览与导出范围 + +- Markdown:编辑和安全渲染预览; +- PDF:内嵌预览; +- 图片:PNG、JPEG、GIF、WebP、SVG(SVG 净化后预览); +- 文本和代码:只读文本; +- 音视频:浏览器原生播放器; +- DOCX:只读内容预览; +- XLS/XLSX:只读表格预览; +- 其他格式:显示元数据并允许项目成员下载。 + +Markdown 导出支持 Markdown、PDF 和 DOCX。导出由后端生成临时响应,不写回文件树。 +PDF 使用随服务端发布的中文字体。复杂 HTML、Mermaid、数学公式和远程图片不在本轮保证范围。 + +## 上传 + +每个文件独立请求,单文件上限 50 MB,客户端最多并发 3 个。上传区显示当前文件、总进度、 +成功/失败计数,并允许展开查看、取消和重试。Markdown 上传后成为可编辑数据库正文,其他 +文件作为二进制文档。 + +## 跨频道入口 + +前端使用幂等的 `DocumentOpenIntent` 接收其他频道的 `create/open` 请求。Inbox、AI 和探索页 +只做接入 document 的最小改动;AI 内容仍须用户确认后才能成为正式对象。Inbox 生成的文档 +保留来源 Inbox ID。 + +## 安全 + +- 服务端校验项目所有权、父目录项目归属、扩展名、MIME 和内容嗅探结果; +- 磁盘使用随机存储键; +- Markdown/HTML/SVG 预览禁止脚本与危险 URL; +- Office 预览不执行宏、外链或嵌入对象; +- 匿名分享页使用严格 CSP 和 `noindex`; +- 为后续病毒扫描状态预留字段,本轮不接入病毒扫描。 + +## 响应式 + +桌面端使用左右分栏。窄屏下文件树与编辑器切换展示,不强制压缩为双栏。