feat: consolidate local-first mnote web runtime
This commit is contained in:
+2
-7
@@ -15,13 +15,8 @@ command -v python3 >/dev/null || {
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -d "$ROOT_DIR/wolai-frontend" ]; then
|
||||
if ! command -v node >/dev/null; then
|
||||
echo "WARN: 缺少 node,前端相关任务可能无法执行"
|
||||
fi
|
||||
if ! command -v pnpm >/dev/null; then
|
||||
echo "WARN: 缺少 pnpm,前端相关任务可能无法执行"
|
||||
fi
|
||||
if [ -f "$ROOT_DIR/package.json" ] && ! command -v node >/dev/null; then
|
||||
echo "WARN: 缺少 node,脚本任务可能无法执行"
|
||||
fi
|
||||
|
||||
echo "Harness 环境检查完成"
|
||||
|
||||
-1114
File diff suppressed because it is too large
Load Diff
Generated
+469
-11
@@ -7,19 +7,287 @@
|
||||
"": {
|
||||
"name": "mnote-desktop",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@e965/xlsx": "0.20.3",
|
||||
"docx-preview": "0.3.7",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"pptx-preview": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"playwright": "^1.56.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@e965/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.100",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.1"
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -28,6 +296,37 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"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/docx-preview": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz",
|
||||
"integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jszip": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
@@ -43,14 +342,77 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"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/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/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/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz",
|
||||
"integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -63,9 +425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -74,6 +436,102 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/pptx-preview": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pptx-preview/-/pptx-preview-1.0.7.tgz",
|
||||
"integrity": "sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.1",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"tslib": "^2.7.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"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/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/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/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/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/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"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/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -3,13 +3,17 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"desktop": "node scripts/desktop-prod.js",
|
||||
"desktop:hot": "node scripts/desktop-hot.js",
|
||||
"dev:hot": "node scripts/dev-hot.js",
|
||||
"check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js"
|
||||
"dev:hot": "node scripts/dev-hot.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"playwright": "^1.56.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@e965/xlsx": "0.20.3",
|
||||
"docx-preview": "0.3.7",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"pptx-preview": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+232
@@ -7,6 +7,16 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@e965/xlsx':
|
||||
specifier: 0.20.3
|
||||
version: 0.20.3
|
||||
docx-preview:
|
||||
specifier: 0.3.7
|
||||
version: 0.3.7
|
||||
pdfjs-dist:
|
||||
specifier: 4.10.38
|
||||
version: 4.10.38
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.56.1
|
||||
@@ -17,16 +27,119 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@e965/xlsx@0.20.3':
|
||||
resolution: {integrity: sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@playwright/test@1.57.0':
|
||||
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pdfjs-dist@4.10.38:
|
||||
resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
playwright-core@1.57.0:
|
||||
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -37,15 +150,112 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@e965/xlsx@0.20.3': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.100':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.100
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.100
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.57.0':
|
||||
dependencies:
|
||||
playwright: 1.57.0
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
docx-preview@0.3.7:
|
||||
dependencies:
|
||||
jszip: 3.10.1
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pdfjs-dist@4.10.38:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.100
|
||||
|
||||
playwright-core@1.57.0: {}
|
||||
|
||||
playwright@1.57.0:
|
||||
@@ -53,3 +263,25 @@ snapshots:
|
||||
playwright-core: 1.57.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
Generated
-48
@@ -264,7 +264,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
@@ -486,7 +485,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -532,7 +530,6 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
name = "core-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"core-domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1543,12 +1540,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1624,15 +1615,12 @@ dependencies = [
|
||||
name = "mnote-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bridge-runtime",
|
||||
"clap",
|
||||
"core-protocol",
|
||||
"mnote-editor-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1662,7 +1650,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -2279,19 +2266,6 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.38"
|
||||
@@ -2615,15 +2589,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "storage-convex-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"core-domain",
|
||||
"core-protocol",
|
||||
"event-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -2711,19 +2676,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -9,7 +9,6 @@ members = [
|
||||
"crates/mnote-editor-core",
|
||||
"crates/mnote-cli",
|
||||
"crates/mnote-web",
|
||||
"crates/storage-convex-bridge",
|
||||
"crates/index-fts",
|
||||
"crates/tree-shell-runtime-wasm",
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ core-domain = { path = "../core-domain" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
event-log = { path = "../event-log" }
|
||||
index-fts = { path = "../index-fts" }
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
@@ -44,13 +44,168 @@ use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::env;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use storage_convex_bridge::{
|
||||
build_query_request, build_write_request, BridgeContext, BridgeError, BridgeErrorKind,
|
||||
};
|
||||
|
||||
static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeContext {
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub actor_type: String,
|
||||
pub actor_id: String,
|
||||
pub session_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub tenant_id: Option<String>,
|
||||
pub auth_token: Option<String>,
|
||||
pub source_channel: String,
|
||||
pub source_client: String,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub validate_only: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BridgeErrorKind {
|
||||
Validation,
|
||||
Unauthorized,
|
||||
Conflict,
|
||||
NotFound,
|
||||
Transport,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeError {
|
||||
pub kind: BridgeErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||
|
||||
impl BridgeError {
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Validation,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transport(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Transport,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::NotFound,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetiredMutationRequest {
|
||||
pub function_name: String,
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub actor_id: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetiredQueryRequest {
|
||||
pub function_name: String,
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub actor_id: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
fn retired_bridge_error() -> BridgeError {
|
||||
BridgeError::validation(
|
||||
"旧 Convex 兼容桥已退役;请改用 local-first Rust/SQLite control-plane 路径",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_query_request<T>(
|
||||
context: &BridgeContext,
|
||||
query: &QueryEnvelope<T>,
|
||||
) -> BridgeResult<RetiredQueryRequest> {
|
||||
let function_name = legacy_query_function_name(&query.name)?;
|
||||
Ok(RetiredQueryRequest {
|
||||
function_name: function_name.to_string(),
|
||||
deployment_id: context.deployment_id.clone(),
|
||||
project_id: context.project_id.clone(),
|
||||
workspace_id: context.workspace_id.clone(),
|
||||
request_id: context.request_id.clone(),
|
||||
trace_id: context.trace_id.clone(),
|
||||
actor_id: context.actor_id.clone(),
|
||||
payload_json: json!({ "name": query.name }).to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_write_request<T>(
|
||||
context: &BridgeContext,
|
||||
command: &CommandEnvelope<T>,
|
||||
) -> BridgeResult<RetiredMutationRequest> {
|
||||
let function_name = legacy_command_function_name(&command.name)?;
|
||||
Ok(RetiredMutationRequest {
|
||||
function_name: function_name.to_string(),
|
||||
deployment_id: context.deployment_id.clone(),
|
||||
project_id: context.project_id.clone(),
|
||||
workspace_id: context.workspace_id.clone(),
|
||||
request_id: context.request_id.clone(),
|
||||
trace_id: context.trace_id.clone(),
|
||||
idempotency_key: context.idempotency_key.clone(),
|
||||
actor_id: context.actor_id.clone(),
|
||||
payload_json: json!({
|
||||
"name": command.name,
|
||||
"commandId": command.command_id,
|
||||
})
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> {
|
||||
match name {
|
||||
"documents.content.get" => Ok("documents:getContent"),
|
||||
"documents.meta.get" => Ok("documents:getMeta"),
|
||||
"mindmaps.get" => Ok("mindmaps:get"),
|
||||
"search.documents" => Ok("documents:listSearchDataByWorkspace"),
|
||||
"search_blocks" => Ok("documents:searchBlocks"),
|
||||
"sidebar.dataset.list" => Ok("sidebar:datasetList"),
|
||||
_ => Err(retired_bridge_error()),
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
|
||||
match name {
|
||||
"documents.title.update" => Ok("documents:updateTitle"),
|
||||
"documents.save" => Ok("documents:updateContent"),
|
||||
"documents.create" => Ok("documents:createWithParentReference"),
|
||||
"documents.move" => Ok("documents:move"),
|
||||
"documents.delete" => Ok("documents:softDelete"),
|
||||
"documents.restore" => Ok("documents:restore"),
|
||||
"insert_block" | "blocks.patch" | "blocks.move" | "blocks.embed" => {
|
||||
Ok("documents:updateContent")
|
||||
}
|
||||
"mindmaps.put" => Ok("mindmaps:put"),
|
||||
_ => Err(retired_bridge_error()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum RuntimeInput {
|
||||
@@ -1573,7 +1728,7 @@ fn normalize_resource_asset_type(raw: Option<&str>) -> String {
|
||||
"video" => "video".into(),
|
||||
"audio" => "audio".into(),
|
||||
"mindmap" => "mindmap".into(),
|
||||
"luckysheet" => "luckysheet".into(),
|
||||
"table" => "table".into(),
|
||||
_ => "file".into(),
|
||||
}
|
||||
}
|
||||
@@ -1633,7 +1788,7 @@ fn normalize_resource_lifecycle_kind(payload: &ResourceLifecycleCommandPayload)
|
||||
.map(str::trim)
|
||||
.unwrap_or("file")
|
||||
{
|
||||
"table" | "online-table" | "luckysheet" => "table".into(),
|
||||
"table" | "online-table" => "table".into(),
|
||||
"mindmap" | "map" => "mindmap".into(),
|
||||
_ => "file".into(),
|
||||
}
|
||||
@@ -7991,7 +8146,7 @@ fn classify_asset_kind(
|
||||
if lowered_type == "mindmap" {
|
||||
return KernelProjectionAssetKind::Mindmap;
|
||||
}
|
||||
if lowered_type == "luckysheet" {
|
||||
if lowered_type == "table" {
|
||||
return KernelProjectionAssetKind::Table;
|
||||
}
|
||||
if is_pdf_asset(file_name, mime_type) {
|
||||
@@ -19981,10 +20136,10 @@ mod tests {
|
||||
"id": "table_1",
|
||||
"workspace_id": "ws_1",
|
||||
"document_id": "page_root",
|
||||
"asset_type": "luckysheet",
|
||||
"file_name": "budget.luckysheet",
|
||||
"asset_type": "table",
|
||||
"file_name": "budget.table",
|
||||
"mime_type": "application/json",
|
||||
"storage_path": "budget.luckysheet"
|
||||
"storage_path": "budget.table"
|
||||
}
|
||||
],
|
||||
"mindmap_asset_children": {
|
||||
|
||||
@@ -3,9 +3,8 @@ use std::io::{self, Read};
|
||||
use bridge_runtime::{
|
||||
build_artifact_success_response, build_failure_response, build_success_response,
|
||||
execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeFailure, RuntimeInput,
|
||||
runtime_input_requests_result, BridgeError, BridgeErrorKind, RuntimeFailure, RuntimeInput,
|
||||
};
|
||||
use storage_convex_bridge::{BridgeError, BridgeErrorKind};
|
||||
|
||||
fn main() {
|
||||
let input = match read_stdin() {
|
||||
|
||||
@@ -14,6 +14,3 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS user_navigation_recent (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL DEFAULT 'local_folder',
|
||||
root_uri TEXT NOT NULL,
|
||||
target_key TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
document_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
visited_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(user_id, kind, target_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_navigation_recent_lookup
|
||||
ON user_navigation_recent(user_id, kind, status, visited_at DESC);
|
||||
@@ -26,6 +26,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
||||
"v5-sidebar-shortcut-root-uri",
|
||||
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"),
|
||||
),
|
||||
(
|
||||
"v6-navigation-recent",
|
||||
include_str!("../migrations/006-navigation-recent.sql"),
|
||||
),
|
||||
];
|
||||
|
||||
/// Create the `_migrations` meta-table if it does not exist.
|
||||
@@ -108,6 +112,7 @@ mod tests {
|
||||
"ai_runtime_events",
|
||||
"sidebar_shortcuts",
|
||||
"user_ui_preferences",
|
||||
"user_navigation_recent",
|
||||
] {
|
||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,40 @@ pub struct UpsertUserUiPreferenceInput {
|
||||
pub value_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NavigationRecentRecord {
|
||||
pub id: EntityId,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub root_uri: String,
|
||||
pub target_key: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub status: String,
|
||||
pub metadata_json: String,
|
||||
pub visited_at: Timestamp,
|
||||
pub created_at: Timestamp,
|
||||
pub updated_at: Timestamp,
|
||||
pub revision: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct UpsertNavigationRecentInput {
|
||||
pub id: Option<EntityId>,
|
||||
pub user_id: EntityId,
|
||||
pub workspace_id: Option<EntityId>,
|
||||
pub kind: String,
|
||||
pub source_kind: String,
|
||||
pub root_uri: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub title: String,
|
||||
pub metadata_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AiPolicyRecord {
|
||||
pub id: EntityId,
|
||||
|
||||
@@ -11,10 +11,11 @@ use crate::model::{
|
||||
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
|
||||
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
|
||||
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
|
||||
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession,
|
||||
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput,
|
||||
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
|
||||
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
|
||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
|
||||
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
@@ -230,6 +231,56 @@ fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUi
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_navigation_recent(row: &rusqlite::Row<'_>) -> rusqlite::Result<NavigationRecentRecord> {
|
||||
Ok(NavigationRecentRecord {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
workspace_id: empty_string_to_option(row.get(2)?),
|
||||
kind: row.get(3)?,
|
||||
source_kind: row.get(4)?,
|
||||
root_uri: row.get(5)?,
|
||||
target_key: row.get(6)?,
|
||||
relative_path: row.get(7)?,
|
||||
document_id: row.get(8)?,
|
||||
title: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
metadata_json: row.get(11)?,
|
||||
visited_at: row.get(12)?,
|
||||
created_at: row.get(13)?,
|
||||
updated_at: row.get(14)?,
|
||||
revision: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn navigation_recent_target_key(
|
||||
kind: &str,
|
||||
root_uri: &str,
|
||||
relative_path: Option<&str>,
|
||||
document_id: Option<&str>,
|
||||
) -> Result<String, ControlPlaneError> {
|
||||
match kind {
|
||||
"folder" => Ok(format!(
|
||||
"folder:{}:{}",
|
||||
root_uri.trim(),
|
||||
relative_path.map(str::trim).unwrap_or_default()
|
||||
)),
|
||||
"page" => {
|
||||
let document_id = document_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ControlPlaneError::InvalidInput(
|
||||
"navigation recent page 需要 document_id".to_string(),
|
||||
)
|
||||
})?;
|
||||
Ok(format!("page:{}:{document_id}", root_uri.trim()))
|
||||
}
|
||||
_ => Err(ControlPlaneError::InvalidInput(
|
||||
"navigation recent kind 只能是 folder 或 page".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
|
||||
Ok(AiPolicyRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -313,6 +364,33 @@ fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
|
||||
title.chars().take(80).collect()
|
||||
}
|
||||
|
||||
fn prune_navigation_recent_for_kind(
|
||||
conn: &Connection,
|
||||
user_id: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), ControlPlaneError> {
|
||||
let keep = match kind {
|
||||
"folder" => 10_i64,
|
||||
"page" => 20_i64,
|
||||
_ => 20_i64,
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE user_navigation_recent
|
||||
SET status = 'removed', updated_at = ?1, revision = revision + 1
|
||||
WHERE user_id = ?2
|
||||
AND kind = ?3
|
||||
AND status = 'active'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM user_navigation_recent
|
||||
WHERE user_id = ?2 AND kind = ?3 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?4
|
||||
)",
|
||||
params![now_text(), user_id, kind, keep],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
|
||||
if input.username.trim().is_empty() {
|
||||
@@ -864,7 +942,8 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
AND status = 'active'
|
||||
AND (
|
||||
?2 = root_uri
|
||||
OR (recursive = 1 AND ?2 LIKE root_uri || '%')
|
||||
OR (recursive = 1 AND ?2 LIKE root_uri || '/%')
|
||||
OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%')
|
||||
OR (?3 != '' AND ?3 = root_path)
|
||||
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
|
||||
)",
|
||||
@@ -1382,6 +1461,138 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
) -> Result<NavigationRecentRecord, ControlPlaneError> {
|
||||
let user_id = input.user_id.trim().to_string();
|
||||
let workspace_id = option_to_stored_text(input.workspace_id);
|
||||
let kind = input.kind.trim().to_string();
|
||||
let source_kind = input.source_kind.trim().to_string();
|
||||
let root_uri = input.root_uri.trim().to_string();
|
||||
let relative_path = input
|
||||
.relative_path
|
||||
.map(|value| value.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let document_id = input
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let title = input.title.trim().to_string();
|
||||
if user_id.is_empty()
|
||||
|| kind.is_empty()
|
||||
|| source_kind.is_empty()
|
||||
|| root_uri.is_empty()
|
||||
|| title.is_empty()
|
||||
{
|
||||
return Err(ControlPlaneError::InvalidInput(
|
||||
"navigation recent user/kind/source/root/title 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
|
||||
let target_key = navigation_recent_target_key(
|
||||
&kind,
|
||||
&root_uri,
|
||||
relative_path.as_deref(),
|
||||
document_id.as_deref(),
|
||||
)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now_text();
|
||||
conn.execute(
|
||||
"INSERT INTO user_navigation_recent (
|
||||
id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'active', ?11, ?12, ?13, ?14, 1)
|
||||
ON CONFLICT(user_id, kind, target_key)
|
||||
DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
source_kind = excluded.source_kind,
|
||||
root_uri = excluded.root_uri,
|
||||
relative_path = excluded.relative_path,
|
||||
document_id = excluded.document_id,
|
||||
title = excluded.title,
|
||||
status = 'active',
|
||||
metadata_json = excluded.metadata_json,
|
||||
visited_at = excluded.visited_at,
|
||||
updated_at = excluded.updated_at,
|
||||
revision = user_navigation_recent.revision + 1",
|
||||
params![
|
||||
input.id.unwrap_or_else(|| new_id("nav_recent")),
|
||||
user_id,
|
||||
workspace_id,
|
||||
kind,
|
||||
source_kind,
|
||||
root_uri,
|
||||
target_key,
|
||||
relative_path,
|
||||
document_id,
|
||||
title,
|
||||
input.metadata_json,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
prune_navigation_recent_for_kind(&conn, &user_id, &kind)?;
|
||||
let record = conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND kind = ?2 AND target_key = ?3
|
||||
LIMIT 1",
|
||||
params![user_id, kind, target_key],
|
||||
row_to_navigation_recent,
|
||||
)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn list_navigation_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let limit = limit.clamp(1, 100) as i64;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let rows = if let Some(kind) = kind.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND kind = ?2 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, kind, limit], row_to_navigation_recent)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, workspace_id, kind, source_kind, root_uri, target_key,
|
||||
relative_path, document_id, title, status, metadata_json, visited_at,
|
||||
created_at, updated_at, revision
|
||||
FROM user_navigation_recent
|
||||
WHERE user_id = ?1 AND status = 'active'
|
||||
ORDER BY visited_at DESC, updated_at DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![user_id, limit], row_to_navigation_recent)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
@@ -1985,7 +2196,8 @@ mod tests {
|
||||
use crate::model::{
|
||||
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
|
||||
UpsertUserUiPreferenceInput,
|
||||
};
|
||||
|
||||
fn store() -> SqliteControlPlaneStore {
|
||||
@@ -2158,6 +2370,36 @@ mod tests {
|
||||
assert_eq!(access.grant_ids.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_access_does_not_match_sibling_uri_prefix() {
|
||||
let store = store();
|
||||
create_user(&store, "reader");
|
||||
store
|
||||
.grant_directory_access(DirectoryGrantInput {
|
||||
user_id: "reader".to_string(),
|
||||
workspace_id: None,
|
||||
root_uri: "file:///tmp/mnote-ai-root".to_string(),
|
||||
root_path: "/tmp/mnote-ai-root".to_string(),
|
||||
permission: "write".to_string(),
|
||||
recursive: true,
|
||||
capabilities: vec!["ai".to_string()],
|
||||
source: "admin".to_string(),
|
||||
created_by: None,
|
||||
})
|
||||
.expect("grant root");
|
||||
|
||||
let inside = store
|
||||
.resolve_access("reader", "file:///tmp/mnote-ai-root/page.md")
|
||||
.expect("resolve inside");
|
||||
assert_eq!(inside.permission, "write");
|
||||
|
||||
let sibling = store
|
||||
.resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md")
|
||||
.expect("resolve sibling");
|
||||
assert_eq!(sibling.permission, "none");
|
||||
assert!(sibling.grant_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_link_token_is_hashed_and_revocable() {
|
||||
let store = store();
|
||||
@@ -2474,6 +2716,99 @@ mod tests {
|
||||
assert!(bob_sidebar_preferences.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
|
||||
let store = store();
|
||||
create_user(&store, "alice");
|
||||
create_user(&store, "bob");
|
||||
|
||||
let first = store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "design".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert folder recent");
|
||||
let updated = store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design".to_string()),
|
||||
document_id: None,
|
||||
title: "Design".to_string(),
|
||||
metadata_json: "{\"from\":\"test\"}".to_string(),
|
||||
})
|
||||
.expect("upsert folder recent");
|
||||
assert_eq!(updated.id, first.id);
|
||||
assert_eq!(updated.title, "Design");
|
||||
assert_eq!(updated.revision, first.revision + 1);
|
||||
|
||||
store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some("design/Plan.md".to_string()),
|
||||
document_id: Some("local-md:design~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert page recent");
|
||||
for index in 0..12 {
|
||||
store
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "alice".to_string(),
|
||||
workspace_id: Some("local:_tmp_mnote".to_string()),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: "file:///tmp/mnote".to_string(),
|
||||
relative_path: Some(format!("folder-{index}")),
|
||||
document_id: None,
|
||||
title: format!("folder-{index}"),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert bounded folder recent");
|
||||
}
|
||||
|
||||
let alice_folders = store
|
||||
.list_navigation_recent("alice", Some("folder"), 50)
|
||||
.expect("list alice folders");
|
||||
assert_eq!(alice_folders.len(), 10);
|
||||
assert_eq!(alice_folders[0].kind, "folder");
|
||||
assert_eq!(alice_folders[0].title, "folder-11");
|
||||
assert!(alice_folders.iter().all(|record| record.user_id == "alice"));
|
||||
assert!(!alice_folders.iter().any(|record| record.title == "Design"));
|
||||
|
||||
let alice_pages = store
|
||||
.list_navigation_recent("alice", Some("page"), 20)
|
||||
.expect("list alice pages");
|
||||
assert_eq!(alice_pages.len(), 1);
|
||||
assert_eq!(
|
||||
alice_pages[0].document_id.as_deref(),
|
||||
Some("local-md:design~2FPlan.md")
|
||||
);
|
||||
|
||||
let bob_recent = store
|
||||
.list_navigation_recent("bob", None, 20)
|
||||
.expect("list bob recent");
|
||||
assert!(bob_recent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
|
||||
let store = store();
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::model::{
|
||||
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
|
||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput,
|
||||
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||
SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput,
|
||||
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||
UserUiPreferenceRecord, WorkspaceRecord,
|
||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
|
||||
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
|
||||
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
||||
};
|
||||
|
||||
pub trait ControlPlaneStore: Send + Sync {
|
||||
@@ -136,6 +136,18 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
source_kind: Option<&str>,
|
||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_navigation_recent(
|
||||
&self,
|
||||
input: UpsertNavigationRecentInput,
|
||||
) -> Result<NavigationRecentRecord, ControlPlaneError>;
|
||||
|
||||
fn list_navigation_recent(
|
||||
&self,
|
||||
user_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<NavigationRecentRecord>, ControlPlaneError>;
|
||||
|
||||
fn upsert_ai_policy(
|
||||
&self,
|
||||
input: UpsertAiPolicyInput,
|
||||
|
||||
@@ -6,6 +6,5 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -202,7 +202,12 @@ pub struct DeleteBlock {
|
||||
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
|
||||
/// - `tree.selectionCount` — 当前选中行数 (i64)
|
||||
/// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...)
|
||||
/// - `tree.targetResourceKind` — 当前命令目标资源类型
|
||||
/// - `tree.targetRowKind` — 当前命令目标行类型
|
||||
/// - `tree.targetIsFolder` — 当前命令目标是否文件夹
|
||||
/// - `tree.targetIsAsset` — 当前命令目标是否资源文件
|
||||
/// - `editor.dirty` — bool,当前编辑器是否有未保存修改
|
||||
/// - `editor.dirtyState` — 当前编辑器 dirty 状态 ("clean" | "dirty" | "stale" | ...)
|
||||
/// - `editor.hasSelection` — bool,编辑器是否有文本选区
|
||||
/// - `ai.canWrite` — bool,当前会话是否允许 AI 写入
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -217,8 +222,18 @@ pub struct CommandContext {
|
||||
pub tree_selection_count: i64,
|
||||
/// 选择资源的 kind
|
||||
pub tree_selection_resource_kind: Option<String>,
|
||||
/// 当前命令目标资源 kind
|
||||
pub tree_target_resource_kind: Option<String>,
|
||||
/// 当前命令目标行 kind
|
||||
pub tree_target_row_kind: Option<String>,
|
||||
/// 当前命令目标是否文件夹
|
||||
pub tree_target_is_folder: bool,
|
||||
/// 当前命令目标是否资源文件
|
||||
pub tree_target_is_asset: bool,
|
||||
/// 编辑器是否 dirty
|
||||
pub editor_dirty: bool,
|
||||
/// 编辑器 dirty 状态
|
||||
pub editor_dirty_state: Option<String>,
|
||||
/// 编辑器是否有选区
|
||||
pub editor_has_selection: bool,
|
||||
/// AI 是否可写
|
||||
@@ -233,7 +248,12 @@ impl CommandContext {
|
||||
tree_focus_kind: None,
|
||||
tree_selection_count: 0,
|
||||
tree_selection_resource_kind: None,
|
||||
tree_target_resource_kind: None,
|
||||
tree_target_row_kind: None,
|
||||
tree_target_is_folder: false,
|
||||
tree_target_is_asset: false,
|
||||
editor_dirty: false,
|
||||
editor_dirty_state: Some("clean".to_string()),
|
||||
editor_has_selection: false,
|
||||
ai_can_write: true,
|
||||
}
|
||||
@@ -256,7 +276,21 @@ impl CommandContext {
|
||||
.tree_selection_resource_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"tree.targetResourceKind" => self
|
||||
.tree_target_resource_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"tree.targetRowKind" => self
|
||||
.tree_target_row_kind
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"tree.targetIsFolder" => Some(CommandContextValue::Bool(self.tree_target_is_folder)),
|
||||
"tree.targetIsAsset" => Some(CommandContextValue::Bool(self.tree_target_is_asset)),
|
||||
"editor.dirty" => Some(CommandContextValue::Bool(self.editor_dirty)),
|
||||
"editor.dirtyState" => self
|
||||
.editor_dirty_state
|
||||
.as_deref()
|
||||
.map(|s| CommandContextValue::String(s.to_string())),
|
||||
"editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)),
|
||||
"ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)),
|
||||
_ => None,
|
||||
@@ -293,9 +327,44 @@ impl CommandContext {
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置命令目标 resource kind。
|
||||
pub fn with_tree_target_resource_kind(mut self, kind: &str) -> Self {
|
||||
self.tree_target_resource_kind = Some(kind.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置命令目标 row kind。
|
||||
pub fn with_tree_target_row_kind(mut self, kind: &str) -> Self {
|
||||
self.tree_target_row_kind = Some(kind.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置命令目标是否文件夹。
|
||||
pub fn with_tree_target_is_folder(mut self, is_folder: bool) -> Self {
|
||||
self.tree_target_is_folder = is_folder;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置命令目标是否资源文件。
|
||||
pub fn with_tree_target_is_asset(mut self, is_asset: bool) -> Self {
|
||||
self.tree_target_is_asset = is_asset;
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 editor dirty。
|
||||
pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
|
||||
self.editor_dirty = dirty;
|
||||
if dirty && self.editor_dirty_state.as_deref() == Some("clean") {
|
||||
self.editor_dirty_state = Some("dirty".to_string());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// 用 builder 模式设置 editor dirty 状态。
|
||||
pub fn with_editor_dirty_state(mut self, state: &str) -> Self {
|
||||
let normalized = state.to_lowercase();
|
||||
self.editor_dirty_state = Some(normalized.clone());
|
||||
self.editor_dirty = normalized != "clean";
|
||||
self
|
||||
}
|
||||
|
||||
@@ -800,6 +869,25 @@ mod command_context_tests {
|
||||
assert!(is_command_enabled(&ctx, Some("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_exposes_target_and_dirty_state_keys() {
|
||||
let ctx = CommandContext::new()
|
||||
.with_tree_target_resource_kind("folder")
|
||||
.with_tree_target_row_kind("directory")
|
||||
.with_tree_target_is_folder(true)
|
||||
.with_tree_target_is_asset(false)
|
||||
.with_editor_dirty_state("Dirty")
|
||||
.with_ai_can_write(false);
|
||||
|
||||
assert!(check_when(&ctx, "tree.targetResourceKind == folder").unwrap());
|
||||
assert!(check_when(&ctx, "tree.targetRowKind == directory").unwrap());
|
||||
assert!(check_when(&ctx, "tree.targetIsFolder").unwrap());
|
||||
assert!(!check_when(&ctx, "tree.targetIsAsset").unwrap());
|
||||
assert!(check_when(&ctx, "editor.dirty").unwrap());
|
||||
assert!(check_when(&ctx, "editor.dirtyState == dirty").unwrap());
|
||||
assert!(!check_when(&ctx, "ai.canWrite").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_context_number_comparison() {
|
||||
let ctx = CommandContext::new().with_tree_selection_count(3);
|
||||
|
||||
@@ -94,6 +94,10 @@ pub struct PageBodyWriteRequest {
|
||||
alias = "conflict_detection_key"
|
||||
)]
|
||||
pub expected_file_version: Option<String>,
|
||||
#[serde(default, alias = "write_intent_id")]
|
||||
pub write_intent_id: Option<String>,
|
||||
#[serde(default, alias = "save_operation_id")]
|
||||
pub save_operation_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_content_hash: Option<String>,
|
||||
#[serde(default = "default_page_body_content_format")]
|
||||
@@ -226,6 +230,12 @@ pub struct DocumentBuffer {
|
||||
pub last_saved_at: Option<i64>,
|
||||
/// 外部修改者
|
||||
pub external_actor: Option<String>,
|
||||
/// 最近一次由 MNote 发起写入的意图 ID。
|
||||
#[serde(default)]
|
||||
pub last_write_intent_id: Option<String>,
|
||||
/// 最近一次保存请求实例 ID。
|
||||
#[serde(default)]
|
||||
pub last_save_operation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DocumentBuffer {
|
||||
@@ -240,10 +250,26 @@ impl DocumentBuffer {
|
||||
}
|
||||
|
||||
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
|
||||
self.mark_saved_with_operation(file_version, content_hash, None, None);
|
||||
}
|
||||
|
||||
pub fn mark_saved_with_operation(
|
||||
&mut self,
|
||||
file_version: String,
|
||||
content_hash: String,
|
||||
write_intent_id: Option<String>,
|
||||
save_operation_id: Option<String>,
|
||||
) {
|
||||
self.file_version = Some(file_version);
|
||||
self.base_content_hash = Some(content_hash);
|
||||
self.current_content_hash = None;
|
||||
self.dirty_state = DocBufferDirtyState::Clean;
|
||||
if write_intent_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
|
||||
self.last_write_intent_id = write_intent_id;
|
||||
}
|
||||
if save_operation_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
|
||||
self.last_save_operation_id = save_operation_id;
|
||||
}
|
||||
self.last_saved_at = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -881,6 +907,8 @@ mod tests {
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
|
||||
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
|
||||
"writeIntentId": "intent:editor:abc",
|
||||
"saveOperationId": "save:op:abc",
|
||||
"baseContentHash": "sha256:base",
|
||||
"contentFormat": "editorBlocks",
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
|
||||
@@ -894,6 +922,8 @@ mod tests {
|
||||
request.expected_file_version.as_deref(),
|
||||
Some("local-md:local-md:README.md:1:2:hash")
|
||||
);
|
||||
assert_eq!(request.write_intent_id.as_deref(), Some("intent:editor:abc"));
|
||||
assert_eq!(request.save_operation_id.as_deref(), Some("save:op:abc"));
|
||||
assert_eq!(request.content_format, "editorBlocks");
|
||||
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
|
||||
|
||||
@@ -902,6 +932,8 @@ mod tests {
|
||||
value["expectedFileVersion"],
|
||||
json!("local-md:local-md:README.md:1:2:hash")
|
||||
);
|
||||
assert_eq!(value["writeIntentId"], json!("intent:editor:abc"));
|
||||
assert_eq!(value["saveOperationId"], json!("save:op:abc"));
|
||||
assert_eq!(value["baseContentHash"], json!("sha256:base"));
|
||||
assert_eq!(value["contentFormat"], json!("editorBlocks"));
|
||||
assert_eq!(value["editorSource"], json!("tiptap"));
|
||||
@@ -1009,11 +1041,15 @@ mod tests {
|
||||
last_loaded_at: Some(1_700_000_000_000i64),
|
||||
last_saved_at: Some(1_700_000_000_001i64),
|
||||
external_actor: None,
|
||||
last_write_intent_id: Some("intent:editor:abc".into()),
|
||||
last_save_operation_id: Some("save:op:abc".into()),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
|
||||
assert_eq!(value["dirtyState"], json!("clean"));
|
||||
assert_eq!(value["fileVersion"], json!("v1"));
|
||||
assert_eq!(value["lastWriteIntentId"], json!("intent:editor:abc"));
|
||||
assert_eq!(value["lastSaveOperationId"], json!("save:op:abc"));
|
||||
assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
|
||||
|
||||
let decoded: DocumentBuffer =
|
||||
@@ -1046,6 +1082,8 @@ mod tests {
|
||||
last_loaded_at: None,
|
||||
last_saved_at: None,
|
||||
external_actor: None,
|
||||
last_write_intent_id: None,
|
||||
last_save_operation_id: None,
|
||||
};
|
||||
|
||||
assert!(!buf.is_dirty());
|
||||
|
||||
@@ -6,12 +6,9 @@ license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
bridge-runtime = { path = "../bridge-runtime" }
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
当前 crate 是 Phase 2 的最小 CLI 协议入口。
|
||||
|
||||
注意:当前 `page` / `block` / `tool run doc_*` 执行面仍是 Convex
|
||||
`documents.*` 兼容路径,只适合调试、迁移验证和 cloud workspace 兼容场景。
|
||||
注意:旧 `page` / `block` / `tool run doc_*` 执行面曾连接 Convex
|
||||
`documents.*` 兼容路径;该执行链已退役,只保留 JSON 计划输出和历史迁移对照。
|
||||
local-first 普通 Markdown 正文主链应优先走本地文件引用、Page Aggregate、
|
||||
Rust Web local_folder source 和 agent 原生 patch/diff,不应把本 CLI 视为正文真源入口。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use base64::Engine;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
||||
build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeToolInvocationWire,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use core_protocol::{
|
||||
@@ -16,11 +16,8 @@ use mnote_editor_core::{
|
||||
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
|
||||
VisibilitySnapshot,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CliContext {
|
||||
@@ -1554,7 +1551,7 @@ fn execute_query(
|
||||
"documents.content.get"
|
||||
| "documents.meta.get"
|
||||
| "mindmaps.get"
|
||||
| "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx),
|
||||
| "sidebar.dataset.list" => execute_retired_cloud_transport(name),
|
||||
"search.documents" => execute_search_documents(transport, context, cli_ctx),
|
||||
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
|
||||
}
|
||||
@@ -1574,7 +1571,7 @@ fn execute_command(
|
||||
| "documents.restore"
|
||||
| "documents.title.update"
|
||||
| "documents.save"
|
||||
| "mindmaps.put" => execute_convex_mutation(transport, cli_ctx),
|
||||
| "mindmaps.put" => execute_retired_cloud_transport(name),
|
||||
"insert_block" => execute_block_insert(normalized_input, context, cli_ctx),
|
||||
"blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx),
|
||||
"blocks.move" => execute_block_move(normalized_input, cli_ctx),
|
||||
@@ -1649,313 +1646,35 @@ fn merge_tool_args(
|
||||
}
|
||||
|
||||
fn execute_search_documents(
|
||||
transport: &CliTransportPlan,
|
||||
context: &CliOutputContext,
|
||||
cli_ctx: &CliContext,
|
||||
_transport: &CliTransportPlan,
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let workspace_id = transport
|
||||
.args_json
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("search documents 缺少 workspaceId"))?;
|
||||
let documents = execute_convex_query_raw(
|
||||
"documents:listSearchDataByWorkspace",
|
||||
json!({ "workspaceId": workspace_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let mindmaps = execute_convex_query_raw(
|
||||
"mindmaps:listByWorkspace",
|
||||
json!({ "workspaceId": workspace_id, "includeDeleted": false }),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let tables = execute_convex_query_raw(
|
||||
"tables:listByWorkspaceForSearch",
|
||||
json!({
|
||||
"userId": context.actor_id,
|
||||
"workspaceId": workspace_id,
|
||||
"includeArchived": false,
|
||||
"limit": 3000,
|
||||
}),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let table_rows = execute_convex_query_raw(
|
||||
"tables:listRowsByWorkspaceForSearch",
|
||||
json!({
|
||||
"userId": context.actor_id,
|
||||
"workspaceId": workspace_id,
|
||||
"limit": 8000,
|
||||
}),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let assets = execute_convex_query_raw(
|
||||
"mediaAssets:listSearchDataByWorkspace",
|
||||
json!({
|
||||
"userId": context.actor_id,
|
||||
"workspaceId": workspace_id,
|
||||
"includeDeleted": false,
|
||||
"limit": 5000,
|
||||
}),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let dataset = json!({
|
||||
"documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)),
|
||||
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
||||
"rawText": item.get("raw_text").cloned().unwrap_or(Value::Null),
|
||||
"createdAt": item.get("created_at").cloned().unwrap_or(Value::Null),
|
||||
"updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<Value>>(),
|
||||
"mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"data": item.get("data").cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<Value>>(),
|
||||
"tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<Value>>(),
|
||||
"tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"tableId": item.get("table_id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<Value>>(),
|
||||
"assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"assetType": item.get("asset_type").cloned().unwrap_or(Value::Null),
|
||||
"fileName": item.get("file_name").cloned().unwrap_or(Value::Null),
|
||||
"mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null),
|
||||
"ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null),
|
||||
"ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null),
|
||||
})).collect::<Vec<Value>>(),
|
||||
});
|
||||
execute_runtime_query(RuntimeInput::Query {
|
||||
context: build_runtime_context(context, cli_ctx),
|
||||
query: RuntimeQueryEnvelopeWire {
|
||||
name: "search.documents".into(),
|
||||
payload: transport.args_json.clone(),
|
||||
},
|
||||
data: Some(dataset),
|
||||
})
|
||||
.map_err(|error| CliError::transport(error.message))
|
||||
execute_retired_cloud_transport("search.documents")
|
||||
}
|
||||
|
||||
fn execute_block_insert(
|
||||
normalized_input: &Value,
|
||||
context: &CliOutputContext,
|
||||
cli_ctx: &CliContext,
|
||||
_normalized_input: &Value,
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let page_id = required_str(normalized_input, "pageId")?;
|
||||
let workspace_id = required_str(normalized_input, "workspaceId")?;
|
||||
let content = required_str(normalized_input, "content")?;
|
||||
let block_type = normalized_input
|
||||
.get("blockType")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("paragraph");
|
||||
let before_block_id = None::<String>;
|
||||
let after_block_id = normalized_input
|
||||
.get("prevBlockId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let data = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?;
|
||||
let block_specs = json!([{
|
||||
"type": block_type,
|
||||
"text": content,
|
||||
}]);
|
||||
let runtime_result = execute_runtime_query(RuntimeInput::Tool {
|
||||
context: build_runtime_context(context, cli_ctx),
|
||||
tool: RuntimeToolInvocationWire {
|
||||
tool: "doc_insert_blocks".into(),
|
||||
kind: "command".into(),
|
||||
mode: Some("result".into()),
|
||||
args_json: json!({
|
||||
"pageId": page_id,
|
||||
"workspaceId": workspace_id,
|
||||
"afterBlockId": after_block_id,
|
||||
"beforeBlockId": before_block_id,
|
||||
"blocks": block_specs,
|
||||
}),
|
||||
target: Some(RuntimeTargetWire {
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
page_id: Some(page_id.to_string()),
|
||||
block_id: None,
|
||||
}),
|
||||
reason: cli_ctx.reason.clone(),
|
||||
refs: vec!["mnote-cli-execute".into()],
|
||||
},
|
||||
data: Some(data.clone()),
|
||||
})
|
||||
.map_err(|error| CliError::transport(error.message))?;
|
||||
let next_content = write_blocks_back(
|
||||
&data,
|
||||
runtime_result
|
||||
.get("data")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Array(vec![])),
|
||||
);
|
||||
let save_result = execute_convex_mutation_raw(
|
||||
"documents:updateContent",
|
||||
json!({
|
||||
"id": page_id,
|
||||
"content": next_content,
|
||||
"expectedRevision": data.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": data.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"pageId": page_id,
|
||||
"inserted": runtime_result.get("inserted").cloned().unwrap_or(Value::Array(vec![])),
|
||||
"revision": save_result.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": save_result.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
||||
"data": runtime_result.get("data").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
execute_retired_cloud_transport("insert_block")
|
||||
}
|
||||
|
||||
fn execute_block_patch(
|
||||
normalized_input: &Value,
|
||||
transport: &CliTransportPlan,
|
||||
cli_ctx: &CliContext,
|
||||
_normalized_input: &Value,
|
||||
_transport: &CliTransportPlan,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let page_id = required_str(normalized_input, "pageId")?;
|
||||
let block_id = required_str(normalized_input, "blockId")?;
|
||||
let snapshot = normalized_input
|
||||
.get("snapshot")
|
||||
.cloned()
|
||||
.ok_or_else(|| CliError::validation("block patch 缺少 snapshot"))?;
|
||||
let current =
|
||||
execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?;
|
||||
let next_blocks = replace_block_subtree(extract_blocks(¤t), block_id, &snapshot)?;
|
||||
let args = json!({
|
||||
"id": page_id,
|
||||
"content": write_blocks_back(¤t, Value::Array(next_blocks)),
|
||||
"expectedRevision": transport.args_json.get("expectedRevision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": transport.args_json.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
execute_convex_mutation_raw("documents:updateContent", args, cli_ctx)
|
||||
execute_retired_cloud_transport("blocks.patch")
|
||||
}
|
||||
|
||||
fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
let source_document_id = required_str(normalized_input, "sourceDocumentId")?;
|
||||
let target_document_id = required_str(normalized_input, "targetDocumentId")?;
|
||||
let block_id = required_str(normalized_input, "blockId")?;
|
||||
|
||||
let source = execute_convex_query_raw(
|
||||
"documents:getContent",
|
||||
json!({ "id": source_document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let target = execute_convex_query_raw(
|
||||
"documents:getContent",
|
||||
json!({ "id": target_document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let source_blocks = extract_blocks(&source);
|
||||
let target_blocks = extract_blocks(&target);
|
||||
let (removed, next_source_blocks) = remove_block_subtree(source_blocks, block_id)?;
|
||||
let mut next_target_blocks = target_blocks;
|
||||
next_target_blocks.push(removed);
|
||||
let next_source_content = write_blocks_back(&source, Value::Array(next_source_blocks));
|
||||
let next_target_content = write_blocks_back(&target, Value::Array(next_target_blocks));
|
||||
let source_save = execute_convex_mutation_raw(
|
||||
"documents:updateContent",
|
||||
json!({
|
||||
"id": source_document_id,
|
||||
"content": next_source_content,
|
||||
"expectedRevision": source.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": source.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let target_save = execute_convex_mutation_raw(
|
||||
"documents:updateContent",
|
||||
json!({
|
||||
"id": target_document_id,
|
||||
"content": next_target_content,
|
||||
"expectedRevision": target.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": target.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"sourceRevision": source_save.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"targetRevision": target_save.get("revision").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.move")
|
||||
}
|
||||
|
||||
fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
let source_document_id = required_str(normalized_input, "sourceDocumentId")?;
|
||||
let target_document_id = required_str(normalized_input, "targetDocumentId")?;
|
||||
let block_id = required_str(normalized_input, "blockId")?;
|
||||
let target_block_id = normalized_input
|
||||
.get("targetBlockId")
|
||||
.and_then(Value::as_str);
|
||||
|
||||
let source_meta = execute_convex_query_raw(
|
||||
"documents:getMeta",
|
||||
json!({ "id": source_document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let target_content = execute_convex_query_raw(
|
||||
"documents:getContent",
|
||||
json!({ "id": target_document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let target_meta = execute_convex_query_raw(
|
||||
"documents:getMeta",
|
||||
json!({ "id": target_document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let mut target_blocks = extract_blocks(&target_content);
|
||||
let reference_block = json!({
|
||||
"id": format!("cli_embed_{}_{}", target_document_id, block_id),
|
||||
"type": "blockReference",
|
||||
"props": {
|
||||
"sourceDocumentId": source_document_id,
|
||||
"targetBlockId": block_id,
|
||||
"display": "embed",
|
||||
},
|
||||
"content": [],
|
||||
"children": [],
|
||||
});
|
||||
let anchor_id = target_block_id.map(str::to_string).or_else(|| {
|
||||
target_meta
|
||||
.get("embed_default_block_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
});
|
||||
if let Some(anchor_id) = anchor_id {
|
||||
if let Some(index) = find_block_index(&target_blocks, &anchor_id) {
|
||||
target_blocks.insert(index + 1, reference_block.clone());
|
||||
} else {
|
||||
target_blocks.push(reference_block.clone());
|
||||
}
|
||||
} else {
|
||||
target_blocks.push(reference_block.clone());
|
||||
}
|
||||
let saved = execute_convex_mutation_raw(
|
||||
"documents:updateContent",
|
||||
json!({
|
||||
"id": target_document_id,
|
||||
"content": write_blocks_back(&target_content, Value::Array(target_blocks)),
|
||||
"expectedRevision": target_content.get("revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": target_content.get("conflict_detection_key").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"sourceTitle": source_meta.get("title").cloned().unwrap_or(Value::Null),
|
||||
"referenceBlockId": reference_block.get("id").cloned().unwrap_or(Value::Null),
|
||||
"revision": saved.get("revision").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.embed")
|
||||
}
|
||||
|
||||
fn build_tool_target(
|
||||
@@ -2007,7 +1726,7 @@ fn load_tool_data(
|
||||
tool_name: &str,
|
||||
args: &Value,
|
||||
target: Option<&RuntimeTargetWire>,
|
||||
cli_ctx: &CliContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
match tool_name {
|
||||
"search_web" | "slash_run" | "event_replay" | "index_rebuild" => {
|
||||
@@ -2015,112 +1734,24 @@ fn load_tool_data(
|
||||
}
|
||||
"image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })),
|
||||
"doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => {
|
||||
let page_id = read_doc_target(args, target)?;
|
||||
let result = execute_convex_query_raw(
|
||||
"documents:getContent",
|
||||
json!({ "id": page_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(
|
||||
json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }),
|
||||
)
|
||||
let _ = read_doc_target(args, target)?;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_read" => {
|
||||
let document_id = args
|
||||
.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?;
|
||||
let meta = execute_convex_query_raw(
|
||||
"documents:getMeta",
|
||||
json!({ "id": document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let content = execute_convex_query_raw(
|
||||
"documents:getContent",
|
||||
json!({ "id": document_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"source": "convex",
|
||||
"documentId": document_id,
|
||||
"title": meta.get("title").cloned().unwrap_or(Value::Null),
|
||||
"workspaceId": meta.get("workspace_id").cloned().unwrap_or(Value::Null),
|
||||
"parentId": meta.get("parent_id").cloned().unwrap_or(Value::Null),
|
||||
"updatedAt": meta.get("updated_at").cloned().unwrap_or(Value::Null),
|
||||
"rawText": extract_raw_text(&content),
|
||||
"rawTextLength": extract_raw_text(&content).chars().count(),
|
||||
"content": content.get("content").cloned().unwrap_or(Value::Null),
|
||||
}))
|
||||
let _ = document_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"docs_search" => {
|
||||
let workspace_id = args
|
||||
.get("workspaceId")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?;
|
||||
let documents = execute_convex_query_raw(
|
||||
"documents:listSearchDataByWorkspace",
|
||||
json!({ "workspaceId": workspace_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
let mindmaps = execute_convex_query_raw(
|
||||
"mindmaps:listByWorkspace",
|
||||
json!({ "workspaceId": workspace_id, "includeDeleted": false }),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let tables = execute_convex_query_raw(
|
||||
"tables:listByWorkspaceForSearch",
|
||||
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000 }),
|
||||
cli_ctx,
|
||||
).unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let table_rows = execute_convex_query_raw(
|
||||
"tables:listRowsByWorkspaceForSearch",
|
||||
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "limit": 8000 }),
|
||||
cli_ctx,
|
||||
)
|
||||
.unwrap_or_else(|_| Value::Array(vec![]));
|
||||
let assets = execute_convex_query_raw(
|
||||
"mediaAssets:listSearchDataByWorkspace",
|
||||
json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000 }),
|
||||
cli_ctx,
|
||||
).unwrap_or_else(|_| Value::Array(vec![]));
|
||||
Ok(json!({
|
||||
"source": "convex",
|
||||
"datasets": [{
|
||||
"workspaceId": workspace_id,
|
||||
"documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)),
|
||||
"title": item.get("title").cloned().unwrap_or(Value::Null),
|
||||
"rawText": item.get("raw_text").cloned().unwrap_or(Value::Null),
|
||||
"createdAt": item.get("created_at").cloned().unwrap_or(Value::Null),
|
||||
"updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null)
|
||||
})).collect::<Vec<Value>>(),
|
||||
"mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"data": item.get("data").cloned().unwrap_or(Value::Null)
|
||||
})).collect::<Vec<Value>>(),
|
||||
"tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"title": item.get("title").cloned().unwrap_or(Value::Null)
|
||||
})).collect::<Vec<Value>>(),
|
||||
"tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"tableId": item.get("table_id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null)
|
||||
})).collect::<Vec<Value>>(),
|
||||
"assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({
|
||||
"id": item.get("id").cloned().unwrap_or(Value::Null),
|
||||
"documentId": item.get("document_id").cloned().unwrap_or(Value::Null),
|
||||
"assetType": item.get("asset_type").cloned().unwrap_or(Value::Null),
|
||||
"fileName": item.get("file_name").cloned().unwrap_or(Value::Null),
|
||||
"mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null),
|
||||
"ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null),
|
||||
"ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null)
|
||||
})).collect::<Vec<Value>>()
|
||||
}]
|
||||
}))
|
||||
let _ = workspace_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
||||
let document_id = read_doc_target(args, target)?;
|
||||
@@ -2129,14 +1760,8 @@ fn load_tool_data(
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| target.and_then(|value| value.block_id.as_deref()))
|
||||
.ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?;
|
||||
let result = execute_convex_query_raw(
|
||||
"mindmaps:get",
|
||||
json!({ "docId": document_id, "mindmapId": mindmap_id }),
|
||||
cli_ctx,
|
||||
)?;
|
||||
Ok(
|
||||
json!({ "source": "convex", "data": result.get("data").cloned().unwrap_or(Value::Null), "meta": result.get("meta").cloned().unwrap_or(Value::Null) }),
|
||||
)
|
||||
let _ = (document_id, mindmap_id);
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
other => Err(CliError::validation(format!(
|
||||
"暂不支持 tool 数据加载: {other}"
|
||||
@@ -2144,183 +1769,14 @@ fn load_tool_data(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_convex_query_raw(
|
||||
&transport.function_name,
|
||||
transport.args_json.clone(),
|
||||
cli_ctx,
|
||||
)
|
||||
fn execute_retired_cloud_transport(operation: &str) -> CliResult<Value> {
|
||||
Err(retired_cloud_transport_error(operation))
|
||||
}
|
||||
|
||||
fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_convex_mutation_raw(
|
||||
&transport.function_name,
|
||||
transport.args_json.clone(),
|
||||
cli_ctx,
|
||||
)
|
||||
}
|
||||
|
||||
fn execute_convex_query_raw(
|
||||
function_name: &str,
|
||||
args_json: Value,
|
||||
cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let env = ConvexCliEnv::load(cli_ctx)?;
|
||||
let payload = json!({
|
||||
"path": function_name,
|
||||
"format": "convex_encoded_json",
|
||||
"args": args_json,
|
||||
});
|
||||
let response = env
|
||||
.client
|
||||
.post(format!("{}/api/query", env.url))
|
||||
.header("Authorization", env.authorization)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Convex-Client", "mnote-cli")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.map_err(|error| CliError::transport(format!("Convex query 请求失败: {error}")))?;
|
||||
parse_convex_response(response, cli_ctx)
|
||||
}
|
||||
|
||||
fn execute_convex_mutation_raw(
|
||||
function_name: &str,
|
||||
args_json: Value,
|
||||
cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let env = ConvexCliEnv::load(cli_ctx)?;
|
||||
let payload = json!({
|
||||
"path": function_name,
|
||||
"format": "convex_encoded_json",
|
||||
"args": [args_json],
|
||||
});
|
||||
let response = env
|
||||
.client
|
||||
.post(format!("{}/api/mutation", env.url))
|
||||
.header("Authorization", env.authorization)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Convex-Client", "mnote-cli")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.map_err(|error| CliError::transport(format!("Convex mutation 请求失败: {error}")))?;
|
||||
parse_convex_response(response, cli_ctx)
|
||||
}
|
||||
|
||||
fn parse_convex_response(
|
||||
response: reqwest::blocking::Response,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.map_err(|error| CliError::transport(format!("Convex 响应解析失败: {error}")))?;
|
||||
if !status.is_success() {
|
||||
let message = body
|
||||
.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
return Err(CliError::transport(message));
|
||||
}
|
||||
match body.get("status").and_then(Value::as_str) {
|
||||
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
||||
Some("error") => Err(CliError::transport(
|
||||
body.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Convex 返回 error")
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Err(CliError::transport(format!("未知 Convex 响应: {body}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ConvexCliEnv {
|
||||
url: String,
|
||||
authorization: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ConvexCliEnv {
|
||||
fn load(cli_ctx: &CliContext) -> CliResult<Self> {
|
||||
let url = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL")?
|
||||
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
|
||||
.ok_or_else(|| {
|
||||
CliError::validation("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL")
|
||||
})?;
|
||||
let admin_key = read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")?
|
||||
.ok_or_else(|| CliError::validation("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?;
|
||||
let dev_user_id = normalize_actor_for_convex_identity(&cli_ctx.actor_id)
|
||||
.or(read_env_or_dotenv("DEV_USER_ID")?)
|
||||
.unwrap_or_else(|| "dev-user".into());
|
||||
let dev_user_name =
|
||||
read_env_or_dotenv("DEV_USER_NAME")?.unwrap_or_else(|| "开发用户".into());
|
||||
let dev_user_email =
|
||||
read_env_or_dotenv("DEV_USER_EMAIL")?.unwrap_or_else(|| "dev@mnote.local".into());
|
||||
let identity = build_convex_dev_identity(&dev_user_id, &dev_user_name, &dev_user_email);
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_string(&identity).map_err(|error| {
|
||||
CliError::transport(format!("开发用户身份序列化失败: {error}"))
|
||||
})?);
|
||||
let authorization = format!("Convex {admin_key}:{encoded}");
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| CliError::transport(format!("HTTP 客户端创建失败: {error}")))?;
|
||||
Ok(Self {
|
||||
url: url.trim().trim_end_matches('/').to_string(),
|
||||
authorization,
|
||||
client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_actor_for_convex_identity(actor_id: &str) -> Option<String> {
|
||||
let trimmed = actor_id.trim();
|
||||
if trimmed.is_empty() || trimmed == "anonymous" || trimmed == "cli_user" {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_convex_dev_identity(user_id: &str, name: &str, email: &str) -> Value {
|
||||
json!({
|
||||
"subject": user_id,
|
||||
"issuer": "https://mnote.local/dev-auth",
|
||||
"tokenIdentifier": format!("dev-user|{}", user_id),
|
||||
"name": name,
|
||||
"email": email,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> CliResult<Option<String>> {
|
||||
if let Ok(value) = env::var(key) {
|
||||
let trimmed = value.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(Some(trimmed));
|
||||
}
|
||||
}
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.join(".env.all");
|
||||
let content = std::fs::read_to_string(&root)
|
||||
.map_err(|error| CliError::transport(format!("读取 .env.all 失败: {error}")))?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
if k.trim() == key {
|
||||
let trimmed = v.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(Some(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
fn retired_cloud_transport_error(operation: &str) -> CliError {
|
||||
CliError::validation(format!(
|
||||
"旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径"
|
||||
))
|
||||
}
|
||||
|
||||
fn build_runtime_context(
|
||||
@@ -2341,6 +1797,10 @@ fn build_runtime_context(
|
||||
source: RuntimeSourceWire {
|
||||
channel: "cli".into(),
|
||||
client: "mnote-cli".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
tenant_id: None,
|
||||
auth_token: None,
|
||||
@@ -2350,125 +1810,6 @@ fn build_runtime_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_blocks(content_result: &Value) -> Vec<Value> {
|
||||
if let Some(content) = content_result.get("content") {
|
||||
if let Some(array) = content.as_array() {
|
||||
return array.clone();
|
||||
}
|
||||
if let Some(array) = content.get("blocks").and_then(Value::as_array) {
|
||||
return array.clone();
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn write_blocks_back(content_result: &Value, blocks: Value) -> Value {
|
||||
let current = content_result
|
||||
.get("content")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
if current.is_array() {
|
||||
return blocks;
|
||||
}
|
||||
if let Some(obj) = current.as_object() {
|
||||
let mut next = obj.clone();
|
||||
next.insert("blocks".into(), blocks);
|
||||
return Value::Object(next);
|
||||
}
|
||||
json!({ "blocks": blocks })
|
||||
}
|
||||
|
||||
fn find_block_index(blocks: &[Value], target_id: &str) -> Option<usize> {
|
||||
blocks.iter().position(|block| {
|
||||
block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value == target_id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_block_subtree(mut blocks: Vec<Value>, block_id: &str) -> CliResult<(Value, Vec<Value>)> {
|
||||
if let Some(index) = blocks
|
||||
.iter()
|
||||
.position(|block| block.get("id").and_then(Value::as_str) == Some(block_id))
|
||||
{
|
||||
let removed = blocks.remove(index);
|
||||
return Ok((removed, blocks));
|
||||
}
|
||||
for block in &mut blocks {
|
||||
if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) {
|
||||
let (removed, next_children) = remove_block_subtree(children.clone(), block_id)?;
|
||||
*children = next_children;
|
||||
return Ok((removed, blocks));
|
||||
}
|
||||
}
|
||||
Err(CliError::validation(format!("未找到 blockId:{block_id}")))
|
||||
}
|
||||
|
||||
fn replace_block_subtree(
|
||||
mut blocks: Vec<Value>,
|
||||
block_id: &str,
|
||||
next_block: &Value,
|
||||
) -> CliResult<Vec<Value>> {
|
||||
let mut normalized = next_block
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| CliError::validation("block patch snapshot 必须是对象"))?;
|
||||
normalized.insert("id".into(), Value::String(block_id.to_string()));
|
||||
let next_value = Value::Object(normalized);
|
||||
|
||||
if let Some(index) = blocks
|
||||
.iter()
|
||||
.position(|block| block.get("id").and_then(Value::as_str) == Some(block_id))
|
||||
{
|
||||
blocks[index] = next_value;
|
||||
return Ok(blocks);
|
||||
}
|
||||
for block in &mut blocks {
|
||||
if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) {
|
||||
let next_children = replace_block_subtree(children.clone(), block_id, &next_value)?;
|
||||
*children = next_children;
|
||||
return Ok(blocks);
|
||||
}
|
||||
}
|
||||
Err(CliError::validation(format!("未找到 blockId:{block_id}")))
|
||||
}
|
||||
|
||||
fn extract_raw_text(content_result: &Value) -> String {
|
||||
fn walk(value: &Value, parts: &mut Vec<String>) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
walk(item, parts);
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
if let Some(text) = map.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(content) = map.get("content") {
|
||||
walk(content, parts);
|
||||
}
|
||||
if let Some(children) = map.get("children") {
|
||||
walk(children, parts);
|
||||
}
|
||||
if let Some(blocks) = map.get("blocks") {
|
||||
walk(blocks, parts);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
if let Some(content) = content_result.get("content") {
|
||||
walk(content, &mut parts);
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult<String> {
|
||||
args.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
@@ -2478,13 +1819,6 @@ fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResul
|
||||
.ok_or_else(|| CliError::validation("缺少 documentId/pageId"))
|
||||
}
|
||||
|
||||
fn required_str<'a>(value: &'a Value, key: &str) -> CliResult<&'a str> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| CliError::validation(format!("缺少 {key}")))
|
||||
}
|
||||
|
||||
fn build_query_output(
|
||||
domain: &str,
|
||||
action: &str,
|
||||
@@ -2625,7 +1959,7 @@ fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
||||
fn map_bridge_error(error: BridgeError) -> CliError {
|
||||
CliError::validation(error.message)
|
||||
}
|
||||
|
||||
@@ -2842,32 +2176,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_dev_identity_prefers_explicit_cli_actor() {
|
||||
let ctx = CliContext {
|
||||
actor_id: "nn7bhmt782sykdrecah0rbe2nx867ks1".into(),
|
||||
..CliContext::default()
|
||||
};
|
||||
let user_id = normalize_actor_for_convex_identity(&ctx.actor_id)
|
||||
.expect("显式 CLI actor 应成为 Convex 写入身份");
|
||||
let identity = build_convex_dev_identity(&user_id, "开发用户", "dev@mnote.local");
|
||||
|
||||
assert_eq!(
|
||||
identity["subject"],
|
||||
json!("nn7bhmt782sykdrecah0rbe2nx867ks1")
|
||||
);
|
||||
assert_eq!(
|
||||
identity["tokenIdentifier"],
|
||||
json!("dev-user|nn7bhmt782sykdrecah0rbe2nx867ks1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_dev_identity_ignores_legacy_default_cli_actor() {
|
||||
assert_eq!(normalize_actor_for_convex_identity("cli_user"), None);
|
||||
assert_eq!(normalize_actor_for_convex_identity(" anonymous "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_json_contract_uses_documents_move() {
|
||||
let output = plan_page_move(
|
||||
@@ -3033,55 +2341,4 @@ mod tests {
|
||||
_ => panic!("expected command output"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_block_subtree_preserves_other_blocks() {
|
||||
let next_blocks = replace_block_subtree(
|
||||
vec![
|
||||
json!({
|
||||
"id": "block_1",
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "old"}],
|
||||
"children": [],
|
||||
}),
|
||||
json!({
|
||||
"id": "block_2",
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "keep"}],
|
||||
"children": [],
|
||||
}),
|
||||
],
|
||||
"block_1",
|
||||
&json!({
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "new"}],
|
||||
"children": [],
|
||||
}),
|
||||
)
|
||||
.expect("block replacement should succeed");
|
||||
|
||||
assert_eq!(
|
||||
next_blocks,
|
||||
vec![
|
||||
json!({
|
||||
"id": "block_1",
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "new"}],
|
||||
"children": [],
|
||||
}),
|
||||
json!({
|
||||
"id": "block_2",
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": "keep"}],
|
||||
"children": [],
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_env_or_dotenv_trims_crlf_values() {
|
||||
let got = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL").expect("read env should work");
|
||||
assert_eq!(got.as_deref(), Some("http://127.0.0.1:3210"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-tungstenite = "0.29"
|
||||
|
||||
@@ -141,7 +141,7 @@ import {
|
||||
};
|
||||
const pageAggregateUrlFromDescriptor = (descriptor) => {
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin);
|
||||
url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace');
|
||||
url.searchParams.set('sourceKind', descriptor.sourceKind || 'local_folder');
|
||||
if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
|
||||
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
|
||||
return url;
|
||||
@@ -151,10 +151,10 @@ import {
|
||||
documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId,
|
||||
workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '',
|
||||
paneRole,
|
||||
sourceKind: descriptor.sourceKind || 'convex_workspace',
|
||||
sourceKind: descriptor.sourceKind || 'local_folder',
|
||||
rootUri: descriptor.rootUri || '',
|
||||
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__',
|
||||
saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder'
|
||||
saveEndpoint: (descriptor.sourceKind || 'local_folder') === 'local_folder'
|
||||
? '/api/page-body/write'
|
||||
: '/api/documents/save',
|
||||
titleEndpoint: '/api/documents/title',
|
||||
@@ -225,6 +225,48 @@ import {
|
||||
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
|
||||
return '';
|
||||
};
|
||||
const currentWebShellSourceKind = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('sourceKind') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const currentWebShellRootUri = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('rootUri') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const restoreResourceTabInputFromUrl = () => {
|
||||
const url = currentUrl();
|
||||
const raw = String(url.searchParams.get('resourceTab') || '').trim();
|
||||
if (!raw) return null;
|
||||
const objectIdentity = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw;
|
||||
if (!objectIdentity.startsWith('resource:file:')) return null;
|
||||
const rootUri = currentWebShellRootUri();
|
||||
if (!rootUri) return null;
|
||||
const rest = objectIdentity.slice('resource:file:'.length);
|
||||
const prefix = `${rootUri}:`;
|
||||
if (!rest.startsWith(prefix)) return null;
|
||||
const path = rest.slice(prefix.length).replace(/^\/+/, '');
|
||||
if (!path) return null;
|
||||
const title = path.split('/').filter(Boolean).pop() || path;
|
||||
return {
|
||||
objectIdentity,
|
||||
documentId: currentWebShellDocumentId(),
|
||||
workspaceId: currentWebShellWorkspaceId(),
|
||||
sourceKind: currentWebShellSourceKind() || 'local_folder',
|
||||
rootUri,
|
||||
path,
|
||||
assetId: `local:asset:${path}`,
|
||||
title,
|
||||
fileName: title,
|
||||
openTarget: 'active-tab',
|
||||
};
|
||||
};
|
||||
|
||||
let nextViewId = 1;
|
||||
const mindmapHost = createMindmapHostRuntime({
|
||||
@@ -267,12 +309,18 @@ import {
|
||||
const markSessionExternalConflict = (...args) => documentSessions.markSessionExternalConflict(...args);
|
||||
const queueSessionSave = (...args) => documentSessions.queueSessionSave(...args);
|
||||
const refreshSessionFromExternalChange = (...args) => documentSessions.refreshSessionFromExternalChange(...args);
|
||||
const ensureLocalFolderEventChannel = (...args) => documentSessions.ensureLocalFolderEventChannel(...args);
|
||||
const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args);
|
||||
const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args);
|
||||
|
||||
const updatePaneChrome = (runtimeDescriptor) => {
|
||||
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
|
||||
if (!(pane instanceof HTMLElement)) return;
|
||||
if (runtimeDescriptor.paneRole === 'primary') {
|
||||
document.querySelectorAll('[data-mnote-navigation-page-placeholder][data-pane-role="primary"]').forEach((node) => {
|
||||
if (node instanceof HTMLElement) node.hidden = true;
|
||||
});
|
||||
}
|
||||
const aggregate = runtimeDescriptor.aggregate || {};
|
||||
const bootstrap = runtimeDescriptor.bootstrap || {};
|
||||
const title = aggregate?.head?.title || '无标题';
|
||||
@@ -366,9 +414,94 @@ import {
|
||||
return payload.result;
|
||||
};
|
||||
|
||||
const ensureLazyPrimaryPaneRoot = () => {
|
||||
let root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`);
|
||||
if (root instanceof HTMLElement) return root;
|
||||
const pagePanel = document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]');
|
||||
if (!(pagePanel instanceof HTMLElement)) return null;
|
||||
const pane = document.createElement('section');
|
||||
pane.className = 'document-pane';
|
||||
pane.setAttribute('data-document-pane', 'true');
|
||||
pane.setAttribute('data-pane-role', 'primary');
|
||||
pane.setAttribute('data-pane-document-id', '');
|
||||
pane.setAttribute('data-pane-workspace-id', currentWebShellWorkspaceId());
|
||||
pane.setAttribute('data-pane-visible', 'false');
|
||||
pane.hidden = true;
|
||||
|
||||
const shell = document.createElement('main');
|
||||
shell.className = 'document-shell';
|
||||
shell.setAttribute('data-editor-host', 'leptos_tiptap_island');
|
||||
shell.setAttribute('data-document-id', '');
|
||||
shell.setAttribute('data-workspace-id', currentWebShellWorkspaceId());
|
||||
shell.setAttribute('data-pane-role', 'primary');
|
||||
shell.setAttribute('data-page-wide-layout', 'false');
|
||||
shell.setAttribute('data-page-small-text', 'false');
|
||||
shell.setAttribute('data-layout-density', 'normal');
|
||||
shell.setAttribute('data-page-font', 'default');
|
||||
shell.setAttribute('data-page-show-heading-numbers', 'false');
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'document-shell-header';
|
||||
header.setAttribute('data-page-title-hidden', 'true');
|
||||
header.hidden = true;
|
||||
const titleHeading = document.createElement('h1');
|
||||
titleHeading.className = 'document-title-heading';
|
||||
const titleInput = document.createElement('textarea');
|
||||
titleInput.id = 'mnote-page-title-input';
|
||||
titleInput.className = 'document-title-input';
|
||||
titleInput.setAttribute('aria-label', '页面标题');
|
||||
titleInput.setAttribute('data-page-title-input', 'true');
|
||||
titleInput.setAttribute('data-document-id', '');
|
||||
titleInput.setAttribute('data-workspace-id', currentWebShellWorkspaceId());
|
||||
titleInput.setAttribute('data-pane-role', 'primary');
|
||||
titleInput.setAttribute('data-title-endpoint', '/api/documents/title');
|
||||
titleInput.rows = 1;
|
||||
titleHeading.appendChild(titleInput);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'document-shell-meta';
|
||||
meta.setAttribute('aria-label', '页面元信息');
|
||||
const currentTitle = document.createElement('span');
|
||||
currentTitle.setAttribute('data-page-title-current', 'true');
|
||||
meta.appendChild(currentTitle);
|
||||
header.append(titleHeading, meta);
|
||||
|
||||
const aggregateMarker = document.createElement('section');
|
||||
aggregateMarker.setAttribute('data-page-aggregate-snapshot', 'mnote.page_aggregate.v1');
|
||||
const subtree = document.createElement('section');
|
||||
subtree.setAttribute('data-testid', 'mnote-page-subtree');
|
||||
subtree.setAttribute('data-page-tree-source', 'page_aggregate.tree.pageSubtree');
|
||||
subtree.setAttribute('data-page-subtree-present', 'false');
|
||||
subtree.setAttribute('data-pane-role', 'primary');
|
||||
|
||||
const island = document.createElement('section');
|
||||
island.id = 'mnote-editor-island';
|
||||
island.setAttribute('data-editor-host', 'leptos_tiptap_island');
|
||||
island.setAttribute('data-pane-role', 'primary');
|
||||
root = document.createElement('div');
|
||||
root.id = 'mnote-leptos-tiptap-island-editor-root';
|
||||
root.setAttribute('data-testid', 'mnote-leptos-tiptap-island-editor-root');
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||
root.setAttribute('data-runtime-editor-status', 'booting');
|
||||
root.setAttribute('data-pane-role', 'primary');
|
||||
const observability = document.createElement('div');
|
||||
observability.className = 'sr-only';
|
||||
observability.setAttribute('data-editor-host-observability', 'rust-web-inline-island');
|
||||
observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island');
|
||||
observability.setAttribute('data-editor-host-requested', 'leptos_tiptap_island');
|
||||
observability.setAttribute('data-editor-host-status', 'booting');
|
||||
observability.setAttribute('data-pane-role', 'primary');
|
||||
island.append(root, observability);
|
||||
shell.append(header, aggregateMarker, subtree, island);
|
||||
pane.appendChild(shell);
|
||||
pagePanel.appendChild(pane);
|
||||
return root;
|
||||
};
|
||||
|
||||
const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
|
||||
mindmapHost.unmountMindmapPane(paneRole);
|
||||
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
||||
const root = paneRole === 'primary'
|
||||
? ensureLazyPrimaryPaneRoot()
|
||||
: document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`);
|
||||
const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
|
||||
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
|
||||
const runtimePromise = loadRuntime();
|
||||
@@ -428,14 +561,14 @@ import {
|
||||
return {
|
||||
documentId,
|
||||
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
|
||||
sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace',
|
||||
sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'local_folder',
|
||||
rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
documentId,
|
||||
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '',
|
||||
sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace',
|
||||
sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'local_folder',
|
||||
rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '',
|
||||
};
|
||||
};
|
||||
@@ -529,6 +662,11 @@ import {
|
||||
broadcastSessionContent(session, view);
|
||||
}
|
||||
if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) {
|
||||
if (session.sourceKind === 'local_folder' && session.sessionKind !== 'resource') {
|
||||
session.externalChangePending = true;
|
||||
if (session.dirty) queueSessionSave(session);
|
||||
return;
|
||||
}
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
@@ -629,6 +767,9 @@ import {
|
||||
} else {
|
||||
setStatus(runtimeDescriptor, 'ready');
|
||||
}
|
||||
if (session.pageBodySource) runtimeDescriptor.root.setAttribute('data-mnote-page-body-source', session.pageBodySource);
|
||||
if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource);
|
||||
if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
|
||||
enhanceEditorAttachmentLinksSoon();
|
||||
};
|
||||
view.onError = (event) => {
|
||||
@@ -674,6 +815,7 @@ import {
|
||||
resourceTabs = createResourceTabRuntime({
|
||||
loadRuntime,
|
||||
createEditorViewBinding,
|
||||
ensureLocalFolderEventChannel,
|
||||
unmountEditorViewBinding,
|
||||
setStatus,
|
||||
documentSessionRegistry,
|
||||
@@ -828,7 +970,16 @@ import {
|
||||
}, { once: true });
|
||||
|
||||
if (paneRuntimes.length) {
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => {
|
||||
const restoreInput = restoreResourceTabInputFromUrl();
|
||||
if (restoreInput) {
|
||||
return openResourceInActiveTab(restoreInput).catch((error) => {
|
||||
console.warn('mnote resource tab URL 恢复失败', error);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}).catch((error) => {
|
||||
console.error('mnote multi-pane editor mount failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
import {
|
||||
localMarkdownDocumentIdFromRelativePath,
|
||||
localMarkdownRelativePathFromDocumentId,
|
||||
localizeTiptapAssetUrls,
|
||||
} from './document-tiptap-conversion-runtime.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} MNoteWorkspacePath
|
||||
* @property {'mnote.workspace_path.v1'} schema
|
||||
* @property {string} workspaceId
|
||||
* @property {string} sourceKind
|
||||
* @property {string} rootUri
|
||||
* @property {string} relativePath
|
||||
* @property {string} documentId
|
||||
* @property {string} objectIdentity
|
||||
* @property {string} assetId
|
||||
* @property {string} resourceKind
|
||||
*
|
||||
* @typedef {Object} MNoteOpenEditorEntry
|
||||
* @property {string} objectIdentity
|
||||
* @property {MNoteWorkspacePath|null} workspacePath
|
||||
* @property {'primary'|'secondary'} paneRole
|
||||
* @property {string} editorKind
|
||||
* @property {boolean} active
|
||||
* @property {string} dirtyState
|
||||
* @property {boolean} preview
|
||||
* @property {boolean} pinned
|
||||
* @property {number} lastActiveAt
|
||||
*
|
||||
* @typedef {Object} MNoteOpenEditorsSnapshot
|
||||
* @property {'mnote.open_editors_snapshot.v1'} schema
|
||||
* @property {number} generatedAt
|
||||
* @property {string} activeObjectIdentity
|
||||
* @property {MNoteOpenEditorEntry[]} editors
|
||||
* @property {{primary: Object, secondary: Object}} groups
|
||||
*/
|
||||
|
||||
export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
const {
|
||||
loadRuntime,
|
||||
createEditorViewBinding,
|
||||
ensureLocalFolderEventChannel,
|
||||
unmountEditorViewBinding,
|
||||
setStatus,
|
||||
documentSessionRegistry,
|
||||
@@ -62,9 +95,31 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return 'file';
|
||||
};
|
||||
|
||||
const resourceTabWidthType = (entry) => {
|
||||
if (!entry) return '';
|
||||
const kind = String(entry.kind || '').trim();
|
||||
if (kind === 'office') {
|
||||
const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim();
|
||||
if (badgeKind === 'ppt') return 'ppt';
|
||||
if (badgeKind === 'sheet') return 'excel';
|
||||
return 'word';
|
||||
}
|
||||
if (kind === 'pdf') return 'pdf';
|
||||
if (kind === 'mindmap') return 'mindmap';
|
||||
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown';
|
||||
return '';
|
||||
};
|
||||
|
||||
const currentWebShellWorkspaceId = () => {
|
||||
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
|
||||
if (explicit) return explicit;
|
||||
const fromBody = document.body?.dataset?.workspaceId || '';
|
||||
if (fromBody) return String(fromBody).trim();
|
||||
const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]');
|
||||
if (shell instanceof HTMLElement) {
|
||||
const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || '';
|
||||
if (value) return value.trim();
|
||||
}
|
||||
try {
|
||||
return currentUrl().searchParams.get('workspaceId') || '';
|
||||
} catch (_) {
|
||||
@@ -82,6 +137,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const documentIdForPane = (paneRole) => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const nodes = resourceTabHostNodes(role);
|
||||
const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim();
|
||||
if (fromTab) return fromTab;
|
||||
const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`);
|
||||
if (pane instanceof HTMLElement) {
|
||||
const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim();
|
||||
if (fromPane) return fromPane;
|
||||
const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]');
|
||||
if (root instanceof HTMLElement) {
|
||||
const fromRoot = String(root.getAttribute('data-document-id') || '').trim();
|
||||
if (fromRoot) return fromRoot;
|
||||
}
|
||||
}
|
||||
return role === 'primary' ? currentWebShellDocumentId() : '';
|
||||
};
|
||||
|
||||
const currentWebShellSourceKind = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('sourceKind') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebShellRootUri = () => {
|
||||
try {
|
||||
return currentUrl().searchParams.get('rootUri') || '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const buildWorkspacePath = ({
|
||||
workspacePath,
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId,
|
||||
resourceKind,
|
||||
} = {}) => {
|
||||
const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null;
|
||||
if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') {
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(),
|
||||
sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(),
|
||||
rootUri: String(fromInput.rootUri || rootUri || '').trim(),
|
||||
relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(),
|
||||
documentId: String(fromInput.documentId || documentId || '').trim(),
|
||||
objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(),
|
||||
assetId: String(fromInput.assetId || assetId || '').trim(),
|
||||
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
|
||||
};
|
||||
}
|
||||
const id = String(objectIdentity || documentId || assetId || relativePath || '').trim();
|
||||
if (!id) return null;
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(workspaceId || '').trim(),
|
||||
sourceKind: String(sourceKind || '').trim(),
|
||||
rootUri: String(rootUri || '').trim(),
|
||||
relativePath: String(relativePath || '').trim(),
|
||||
documentId: String(documentId || '').trim(),
|
||||
objectIdentity: String(objectIdentity || '').trim(),
|
||||
assetId: String(assetId || '').trim(),
|
||||
resourceKind: String(resourceKind || '').trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeResourceTabKind = (input) => {
|
||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||
@@ -121,7 +250,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
|
||||
const setResourceTabLastActive = (key) => {
|
||||
const entry = resourceTabRegistry.get(String(key || '').trim());
|
||||
if (entry?.session) entry.session.lastActiveAt = Date.now();
|
||||
if (!entry) return;
|
||||
const timestamp = Date.now();
|
||||
entry.lastActiveAt = timestamp;
|
||||
if (entry.session) entry.session.lastActiveAt = timestamp;
|
||||
};
|
||||
|
||||
const removeFromResourceTabMru = (paneRole, key) => {
|
||||
@@ -140,6 +272,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
|
||||
const resourceTabCloseGuardReason = (session) => {
|
||||
if (!session) return '';
|
||||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
|
||||
return bufferDirtyState;
|
||||
}
|
||||
const hasUnsavedChanges = session.dirty
|
||||
|| Boolean(session.saveTimer)
|
||||
|| sessionHasRecentLocalInput(session)
|
||||
@@ -150,7 +286,49 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const syncResourceTabCloseGuard = (entry) => {
|
||||
const documentSessionForPane = (paneRole, documentId) => {
|
||||
const id = String(documentId || '').trim();
|
||||
if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null;
|
||||
const role = normalizePaneRole(paneRole);
|
||||
return Array.from(documentSessionRegistry.values()).find((session) => (
|
||||
session
|
||||
&& session.sessionKind !== 'resource'
|
||||
&& String(session.documentId || '').trim() === id
|
||||
&& Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role)
|
||||
)) || Array.from(documentSessionRegistry.values()).find((session) => (
|
||||
session
|
||||
&& session.sessionKind !== 'resource'
|
||||
&& String(session.documentId || '').trim() === id
|
||||
)) || null;
|
||||
};
|
||||
|
||||
const documentSessionDirtyState = (session) => {
|
||||
if (!session) return '';
|
||||
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
|
||||
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState;
|
||||
if (session.hasExternalConflict) return 'ExternalModified';
|
||||
if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty';
|
||||
if (session.saving) return 'Saving';
|
||||
return '';
|
||||
};
|
||||
|
||||
const resourceTabBufferStateUrl = (entry) => {
|
||||
const session = entry?.session;
|
||||
if (!session || session.sourceKind !== 'local_folder') return null;
|
||||
const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim();
|
||||
const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim();
|
||||
if (!relativePath || !rootUri) return null;
|
||||
const url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || ''));
|
||||
url.searchParams.set('sourceKind', 'local_folder');
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('relativePath', relativePath);
|
||||
const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim();
|
||||
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
|
||||
return url;
|
||||
};
|
||||
|
||||
const applyResourceTabCloseGuard = (entry) => {
|
||||
if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
|
||||
const reason = resourceTabCloseGuardReason(entry.session);
|
||||
if (reason) {
|
||||
@@ -162,6 +340,30 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshResourceTabBufferState = async (entry) => {
|
||||
const url = resourceTabBufferStateUrl(entry);
|
||||
if (!url || !entry?.session) return null;
|
||||
try {
|
||||
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true || !payload.result) return null;
|
||||
const dirtyState = String(payload.result.dirtyState || '').trim();
|
||||
if (dirtyState) entry.session.bufferDirtyState = dirtyState;
|
||||
if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) {
|
||||
entry.session.fileVersion = payload.result.fileVersion.trim();
|
||||
}
|
||||
applyResourceTabCloseGuard(entry);
|
||||
return payload.result;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const syncResourceTabCloseGuard = (entry) => {
|
||||
applyResourceTabCloseGuard(entry);
|
||||
void refreshResourceTabBufferState(entry);
|
||||
};
|
||||
|
||||
const syncResourceSessionTabGuards = (session) => {
|
||||
if (!session || session.sessionKind !== 'resource') return;
|
||||
resourceTabRegistry.forEach((entry) => {
|
||||
@@ -169,52 +371,126 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
const openEditorsSnapshotEntry = (entry, key) => ({
|
||||
objectIdentity: String(entry?.objectIdentity || key || '').trim(),
|
||||
title: String(entry?.title || '资源').trim() || '资源',
|
||||
kind: normalizeResourceTabKind(entry),
|
||||
badgeKind: entry?.tab instanceof HTMLElement
|
||||
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
|
||||
: resourceTabBadgeKind(entry, entry?.kind),
|
||||
active: entry?.tab instanceof HTMLElement
|
||||
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||||
const active = entry?.tab instanceof HTMLElement
|
||||
? entry.tab.getAttribute('aria-selected') === 'true'
|
||||
: false,
|
||||
dirtyGuard: resourceTabCloseGuardReason(entry?.session),
|
||||
assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(),
|
||||
path: String(entry?.path || entry?.session?.resourcePath || '').trim(),
|
||||
});
|
||||
: false;
|
||||
const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim();
|
||||
const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim();
|
||||
const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim();
|
||||
const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim();
|
||||
const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim();
|
||||
const objectIdentity = String(entry?.objectIdentity || key || '').trim();
|
||||
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
||||
const kind = normalizeResourceTabKind(entry);
|
||||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||||
return {
|
||||
objectIdentity,
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId,
|
||||
resourceKind: kind,
|
||||
workspacePath: entry?.workspacePath,
|
||||
}),
|
||||
paneRole: normalizePaneRole(entry?.paneRole),
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: String(entry?.title || '资源').trim() || '资源',
|
||||
kind,
|
||||
editorKind: kind,
|
||||
badgeKind: entry?.tab instanceof HTMLElement
|
||||
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
|
||||
: resourceTabBadgeKind(entry, entry?.kind),
|
||||
active,
|
||||
dirtyState,
|
||||
dirtyGuard: dirtyState,
|
||||
assetId,
|
||||
path: relativePath,
|
||||
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||||
preview: false,
|
||||
pinned: false,
|
||||
};
|
||||
};
|
||||
|
||||
const buildOpenEditorsSnapshot = () => {
|
||||
const generatedAt = Date.now();
|
||||
const pageEntries = ['primary', 'secondary'].map((paneRole) => {
|
||||
const nodes = resourceTabHostNodes(paneRole);
|
||||
const documentId = documentIdForPane(paneRole);
|
||||
const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim();
|
||||
const sourceKind = currentWebShellSourceKind();
|
||||
const rootUri = currentWebShellRootUri();
|
||||
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
||||
const objectIdentity = `page:${paneRole}`;
|
||||
const active = nodes.pageTab instanceof HTMLElement
|
||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||
: false;
|
||||
const pageSession = documentSessionForPane(paneRole, documentId);
|
||||
const dirtyState = documentSessionDirtyState(pageSession);
|
||||
const pageTitle = nodes.pageTab instanceof HTMLElement
|
||||
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
|
||||
: '页面';
|
||||
return {
|
||||
objectIdentity: `page:${paneRole}`,
|
||||
objectIdentity,
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspaceId,
|
||||
sourceKind,
|
||||
rootUri,
|
||||
relativePath,
|
||||
documentId,
|
||||
objectIdentity,
|
||||
assetId: '',
|
||||
resourceKind: 'page',
|
||||
}),
|
||||
paneRole,
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(),
|
||||
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: pageTitle || '页面',
|
||||
kind: 'page',
|
||||
editorKind: 'page',
|
||||
badgeKind: 'code',
|
||||
active: nodes.pageTab instanceof HTMLElement
|
||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||
: false,
|
||||
dirtyGuard: '',
|
||||
active,
|
||||
dirtyState,
|
||||
dirtyGuard: dirtyState,
|
||||
lastActiveAt: active ? generatedAt : 0,
|
||||
preview: false,
|
||||
pinned: true,
|
||||
};
|
||||
}).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
|
||||
const resources = [];
|
||||
resourceTabRegistry.forEach((entry, key) => {
|
||||
resources.push(openEditorsSnapshotEntry(entry, key));
|
||||
resources.push(openEditorsSnapshotEntry(entry, key, generatedAt));
|
||||
});
|
||||
const active = [...pageEntries, ...resources].find((entry) => entry.active);
|
||||
const editors = [...pageEntries, ...resources];
|
||||
const groupForPane = (paneRole) => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||||
const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role);
|
||||
const groupActive = groupEditors.find((entry) => entry.active);
|
||||
return {
|
||||
paneRole: role,
|
||||
activeObjectIdentity: groupActive?.objectIdentity || '',
|
||||
editors: groupEditors,
|
||||
resourceEditors: groupResources,
|
||||
};
|
||||
};
|
||||
const groups = {
|
||||
primary: groupForPane('primary'),
|
||||
secondary: groupForPane('secondary'),
|
||||
};
|
||||
const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || '';
|
||||
return {
|
||||
schema: 'mnote.open_editors_snapshot.v1',
|
||||
generatedAt: Date.now(),
|
||||
activeObjectIdentity: active?.objectIdentity || '',
|
||||
editors: [...pageEntries, ...resources],
|
||||
generatedAt,
|
||||
activeObjectIdentity,
|
||||
editors,
|
||||
resourceEditors: resources,
|
||||
groups,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -295,6 +571,29 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : '';
|
||||
const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement);
|
||||
targetNodes.forEach((node) => {
|
||||
if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||||
else node.removeAttribute('data-mnote-active-resource-width-type');
|
||||
});
|
||||
const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`);
|
||||
if (shell instanceof HTMLElement) {
|
||||
if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType);
|
||||
else shell.removeAttribute('data-mnote-active-resource-width-type');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', {
|
||||
detail: {
|
||||
paneRole: role,
|
||||
active: Boolean(activeEntry),
|
||||
widthType,
|
||||
objectIdentity: String(activeEntry?.objectIdentity || '')
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
|
||||
const role = normalizePaneRole(paneRole);
|
||||
if (role !== 'primary') return;
|
||||
@@ -340,6 +639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
|
||||
syncResourceTabCloseGuard(entry);
|
||||
});
|
||||
syncActiveResourceWidthType(activeEntry, role);
|
||||
if (activeEntry) markIntendedSlashRoot(activeEntry);
|
||||
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
|
||||
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
|
||||
@@ -399,10 +699,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
syncOpenEditorsSnapshot();
|
||||
};
|
||||
|
||||
const closeResourceTab = (objectIdentity) => {
|
||||
const closeResourceTab = async (objectIdentity) => {
|
||||
const key = String(objectIdentity || '').trim();
|
||||
const entry = resourceTabRegistry.get(key);
|
||||
if (!entry) return;
|
||||
await refreshResourceTabBufferState(entry);
|
||||
const guardReason = resourceTabCloseGuardReason(entry.session);
|
||||
if (guardReason) {
|
||||
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
|
||||
@@ -489,6 +790,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
paneRole,
|
||||
title,
|
||||
kind,
|
||||
badgeKind: resourceTabBadgeKind(input, kind),
|
||||
tab,
|
||||
panel,
|
||||
view: null,
|
||||
@@ -497,6 +799,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
path: String(input.path || '').trim(),
|
||||
documentId: String(input.documentId || '').trim(),
|
||||
ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(),
|
||||
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(),
|
||||
sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(),
|
||||
rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(),
|
||||
workspacePath: buildWorkspacePath({
|
||||
workspacePath: input.workspacePath,
|
||||
workspaceId: input.workspaceId || currentWebShellWorkspaceId(),
|
||||
sourceKind: input.sourceKind || currentWebShellSourceKind(),
|
||||
rootUri: input.rootUri || currentWebShellRootUri(),
|
||||
relativePath: input.path,
|
||||
documentId: input.documentId,
|
||||
objectIdentity,
|
||||
assetId: input.assetId,
|
||||
resourceKind: kind,
|
||||
}),
|
||||
lastActiveAt: 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -515,6 +832,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
}
|
||||
entry.mindmapRuntime = null;
|
||||
}
|
||||
if (entry.resourceWatchEventSource) {
|
||||
try {
|
||||
entry.resourceWatchEventSource.close();
|
||||
} catch (_) {}
|
||||
entry.resourceWatchEventSource = null;
|
||||
}
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
|
||||
};
|
||||
|
||||
@@ -525,6 +848,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const localResourceStatUrl = (rootUri, path) => {
|
||||
const url = new URL('/api/local-folder/files/stat', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri || '');
|
||||
url.searchParams.set('path', path || '');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const withResourceReloadToken = (href) => {
|
||||
if (!href) return href;
|
||||
const url = new URL(href, window.location.origin);
|
||||
url.searchParams.set('mnoteResourceReload', String(Date.now()));
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const renderPassiveResourceMissing = (entry, message) => {
|
||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||
entry.panel.setAttribute('data-mnote-resource-missing', 'true');
|
||||
entry.panel.innerHTML = '<div class="mnote-resource-tab-error" data-resource-tab-error="true" data-mnote-resource-missing="true"><div class="mnote-resource-tab-error-inner"><h1>资源已不可用</h1><p></p></div></div>';
|
||||
const text = entry.panel.querySelector('p');
|
||||
if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。';
|
||||
};
|
||||
|
||||
const reloadPassiveResourceTab = (entry) => {
|
||||
if (!(entry?.panel instanceof HTMLElement)) return;
|
||||
entry.panel.removeAttribute('data-mnote-resource-missing');
|
||||
const img = entry.panel.querySelector('img.mnote-resource-tab-image');
|
||||
if (img instanceof HTMLImageElement) {
|
||||
img.src = withResourceReloadToken(img.getAttribute('src') || img.src || '');
|
||||
return;
|
||||
}
|
||||
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || '');
|
||||
}
|
||||
};
|
||||
|
||||
const installPassiveResourceWatch = (entry) => {
|
||||
if (!entry || entry.session || typeof window.EventSource !== 'function') return;
|
||||
const rootUri = String(entry.rootUri || '').trim();
|
||||
const path = String(entry.path || '').trim();
|
||||
if (!rootUri || !path) return;
|
||||
if (entry.resourceWatchEventSource) {
|
||||
try {
|
||||
entry.resourceWatchEventSource.close();
|
||||
} catch (_) {}
|
||||
entry.resourceWatchEventSource = null;
|
||||
}
|
||||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('resourcePath', path);
|
||||
const eventSource = new EventSource(url.toString());
|
||||
entry.resourceWatchEventSource = eventSource;
|
||||
eventSource.addEventListener('ready', () => {
|
||||
if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true');
|
||||
});
|
||||
eventSource.addEventListener('change', async () => {
|
||||
try {
|
||||
const response = await fetch(localResourceStatUrl(rootUri, path), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists);
|
||||
if (!exists) {
|
||||
renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。');
|
||||
return;
|
||||
}
|
||||
reloadPassiveResourceTab(entry);
|
||||
} catch (error) {
|
||||
renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createResourceSession = (entry, input, readResult) => {
|
||||
const resourcePath = String(input.path || '');
|
||||
const tiptapDocument = localizeTiptapAssetUrls(
|
||||
@@ -577,6 +974,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
error: null,
|
||||
};
|
||||
documentSessionRegistry.set(session.key, session);
|
||||
if (typeof ensureLocalFolderEventChannel === 'function') {
|
||||
ensureLocalFolderEventChannel(session);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
@@ -644,6 +1044,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
img.src = href;
|
||||
img.alt = entry.title;
|
||||
}
|
||||
installPassiveResourceWatch(entry);
|
||||
return;
|
||||
}
|
||||
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
|
||||
@@ -652,6 +1053,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
||||
frame.title = entry.title;
|
||||
frame.src = href;
|
||||
}
|
||||
installPassiveResourceWatch(entry);
|
||||
};
|
||||
|
||||
const refreshExistingOfficeResourceTab = (entry, input) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hydrateMindmapAttrsFromDom,
|
||||
legacyBlocksFromEditorDocument,
|
||||
pageBodyTiptapDocument,
|
||||
pageBodyTiptapDocumentSource,
|
||||
revisionFromConflictKey,
|
||||
textToTiptapDocument,
|
||||
toTiptapDocument,
|
||||
@@ -102,7 +103,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const normalizeSessionSourceKind = (bootstrap) => {
|
||||
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
|
||||
return value || 'convex_workspace';
|
||||
return value || 'local_folder';
|
||||
};
|
||||
|
||||
const buildDocumentSessionKey = (bootstrap) => {
|
||||
@@ -115,7 +116,90 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const sessionViews = (session) => Array.from(session.views.values());
|
||||
|
||||
const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`;
|
||||
const resourceWatchPath = (session) => String(session?.resourcePath || session?.relativePath || '').trim();
|
||||
|
||||
const localFolderEventChannelKey = (session) => {
|
||||
const scope = session?.sessionKind === 'resource'
|
||||
? `resource:${resourceWatchPath(session)}`
|
||||
: String(session?.documentId || '').trim();
|
||||
return `${String(session?.rootUri || '').trim()}#${scope}`;
|
||||
};
|
||||
|
||||
const localMarkdownRelativePathFromDocumentId = (documentId) => {
|
||||
const value = String(documentId || '').trim();
|
||||
if (!value.startsWith('local-md:')) return '';
|
||||
return value.slice('local-md:'.length).replace(/~2F/g, '/');
|
||||
};
|
||||
|
||||
const sessionRelativePath = (session) => (
|
||||
String(session?.relativePath || '').trim()
|
||||
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
|
||||
);
|
||||
|
||||
const sessionBufferStateUrl = (session) => {
|
||||
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
|
||||
const url = new URL('/api/documents/buffer-state', window.location.origin);
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
url.searchParams.set('sourceKind', session.sourceKind);
|
||||
url.searchParams.set('rootUri', session.rootUri);
|
||||
if (session.workspaceId) url.searchParams.set('workspaceId', session.workspaceId);
|
||||
const relativePath = sessionRelativePath(session);
|
||||
if (relativePath) url.searchParams.set('relativePath', relativePath);
|
||||
return url;
|
||||
};
|
||||
|
||||
const applyBufferStateToSession = (session, bufferState) => {
|
||||
if (!session || !bufferState || typeof bufferState !== 'object') return;
|
||||
const dirtyState = String(bufferState.dirtyState || '').trim();
|
||||
if (dirtyState) session.bufferDirtyState = dirtyState;
|
||||
if (typeof bufferState.fileVersion === 'string' && bufferState.fileVersion.trim()) {
|
||||
session.fileVersion = bufferState.fileVersion.trim();
|
||||
if (!session.conflictDetectionKey) session.conflictDetectionKey = session.fileVersion;
|
||||
}
|
||||
if (typeof bufferState.externalActor === 'string' && bufferState.externalActor.trim()) {
|
||||
session.lastExternalWriteSource = bufferState.externalActor.trim();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessionBufferState = async (session) => {
|
||||
const url = sessionBufferStateUrl(session);
|
||||
if (!url) return null;
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || payload.ok !== true) return null;
|
||||
const bufferState = payload.result || null;
|
||||
applyBufferStateToSession(session, bufferState);
|
||||
return bufferState;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const markSessionBufferDirty = (session) => {
|
||||
if (!session || session.sourceKind !== 'local_folder' || session.sessionKind === 'resource') return;
|
||||
const relativePath = sessionRelativePath(session);
|
||||
if (!relativePath) return;
|
||||
const serialized = String(session.currentSerialized || '');
|
||||
const contentHash = `browser-dirty:${serialized.length}:${serialized.charCodeAt(0) || 0}:${serialized.charCodeAt(serialized.length - 1) || 0}`;
|
||||
void fetch('/api/documents/buffer-state/dirty', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
relativePath,
|
||||
contentHash,
|
||||
}),
|
||||
}).then((response) => response.json().catch(() => null)).then((payload) => {
|
||||
if (payload && payload.ok === true) applyBufferStateToSession(session, payload.result);
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const detachSessionFromLocalFolderChannel = (session) => {
|
||||
const channel = session.localFolderChannel;
|
||||
@@ -416,16 +500,21 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const ensureSessionConflictEnvelope = (session, message) => {
|
||||
if (!session) return null;
|
||||
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
|
||||
const isResourceSession = session.sessionKind === 'resource';
|
||||
const defaultMessage = isResourceSession
|
||||
? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'
|
||||
: externalConflictMessage;
|
||||
const envelope = {
|
||||
code: 'local_markdown_external_change',
|
||||
code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: isResourceSession ? (session.resourcePath || '') : undefined,
|
||||
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: session.lastExternalWriteSource || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
|
||||
message: message || externalConflictMessage,
|
||||
message: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage),
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
|
||||
};
|
||||
session.lastExternalConflictEnvelope = envelope;
|
||||
@@ -802,13 +891,13 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
ensureSessionConflictEnvelope(session, message);
|
||||
const envelope = ensureSessionConflictEnvelope(session, message);
|
||||
session.hasExternalConflict = true;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
const nextMessage = message || externalConflictMessage;
|
||||
const nextMessage = envelope?.message || message || externalConflictMessage;
|
||||
setSessionStatus(session, 'external-change-conflict', nextMessage);
|
||||
renderSessionConflictSurface(session, nextMessage);
|
||||
};
|
||||
@@ -817,12 +906,23 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (session.readOnly || session.hasExternalConflict) return;
|
||||
if (session.saveTimer) window.clearTimeout(session.saveTimer);
|
||||
setSessionStatus(session, 'dirty');
|
||||
markSessionBufferDirty(session);
|
||||
session.saveTimer = window.setTimeout(() => {
|
||||
session.saveTimer = 0;
|
||||
void persistSession(session);
|
||||
}, 650);
|
||||
};
|
||||
|
||||
const makeSaveOperationToken = (prefix, session) => {
|
||||
const doc = String(session?.documentId || session?.resourcePath || 'unknown')
|
||||
.replace(/[^a-zA-Z0-9._:-]+/g, '-')
|
||||
.slice(0, 80);
|
||||
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return `${prefix}:${doc}:${random}`;
|
||||
};
|
||||
|
||||
const persistSession = async (session) => {
|
||||
if (session.readOnly || session.saving || session.hasExternalConflict) return;
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
@@ -841,11 +941,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const expectedFileVersion = (
|
||||
session.sourceKind === 'local_folder'
|
||||
&& !session.lastExternalConflictEnvelope
|
||||
&& session.lastExternalConflictDetectionKey
|
||||
)
|
||||
? session.lastExternalConflictDetectionKey
|
||||
: session.conflictDetectionKey;
|
||||
if (session.sourceKind === 'local_folder' && expectedFileVersion && expectedFileVersion !== session.conflictDetectionKey) {
|
||||
session.conflictDetectionKey = expectedFileVersion;
|
||||
session.fileVersion = expectedFileVersion;
|
||||
}
|
||||
const writeIntentId = session.sourceKind === 'local_folder'
|
||||
? makeSaveOperationToken('intent:tiptap', session)
|
||||
: '';
|
||||
const saveOperationId = session.sourceKind === 'local_folder'
|
||||
? makeSaveOperationToken('save:tiptap', session)
|
||||
: '';
|
||||
const savePayload = session.sessionKind === 'resource'
|
||||
? {
|
||||
rootUri: session.rootUri,
|
||||
path: session.resourcePath,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
expectedFileVersion: expectedFileVersion,
|
||||
writeIntentId,
|
||||
saveOperationId,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap-resource-tab',
|
||||
editorDocument,
|
||||
@@ -859,7 +978,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
expectedFileVersion: expectedFileVersion,
|
||||
writeIntentId,
|
||||
saveOperationId,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
@@ -889,6 +1010,16 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
throw new Error(message);
|
||||
}
|
||||
const saved = result.result || {};
|
||||
if (typeof saved.writeIntentId === 'string' && saved.writeIntentId.trim()) {
|
||||
session.lastWriteIntentId = saved.writeIntentId.trim();
|
||||
} else if (writeIntentId) {
|
||||
session.lastWriteIntentId = writeIntentId;
|
||||
}
|
||||
if (typeof saved.saveOperationId === 'string' && saved.saveOperationId.trim()) {
|
||||
session.lastSaveOperationId = saved.saveOperationId.trim();
|
||||
} else if (saveOperationId) {
|
||||
session.lastSaveOperationId = saveOperationId;
|
||||
}
|
||||
if (Number.isInteger(saved.revision)) session.revision = saved.revision;
|
||||
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
|
||||
session.conflictDetectionKey = saved.conflict_detection_key.trim();
|
||||
@@ -902,6 +1033,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
session.lastUserInputAt = 0;
|
||||
syncSessionMetaToViews(session);
|
||||
session.lastPersistedSerialized = serialized;
|
||||
@@ -951,7 +1083,47 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind === 'local_folder' && !session.rootUri) return;
|
||||
if (session.sessionKind === 'resource') {
|
||||
await refreshResourceSessionFromExternalChange(session, source);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const bufferState = await fetchSessionBufferState(session);
|
||||
const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim();
|
||||
if (dirtyState === 'Deleted') {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_markdown_deleted',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: bufferState?.externalActor || null,
|
||||
dirtyState,
|
||||
bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null,
|
||||
message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签',
|
||||
suggestedActions: ['keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
if (session.hasExternalConflict) {
|
||||
markSessionExternalConflict(session, conflictMessageFromEnvelope(session, {
|
||||
message: externalConflictMessage,
|
||||
dirtyState,
|
||||
fileVersion: bufferState?.fileVersion || null,
|
||||
externalActor: bufferState?.externalActor || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
session.externalChangePending = true;
|
||||
if (session.dirty && !session.saveTimer && !session.saving) {
|
||||
queueSessionSave(session);
|
||||
} else if (!session.saving) {
|
||||
setSessionStatus(session, 'dirty');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
sourceKind: session.sourceKind,
|
||||
@@ -999,13 +1171,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
||||
if (!contentChanged) return;
|
||||
}
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return;
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
markSessionExternalConflict(session, externalConflictMessage);
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
|
||||
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
syncSessionMetaToViews(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
@@ -1031,6 +1207,78 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshResourceSessionFromExternalChange = async (session, source) => {
|
||||
if (document.hidden) return;
|
||||
if (!session || session.views.size === 0) return;
|
||||
if (session.sourceKind !== 'local_folder' || !session.rootUri || !session.resourcePath) return;
|
||||
let nextResource = null;
|
||||
try {
|
||||
nextResource = await fetchLatestResourceSnapshot(session);
|
||||
} catch (error) {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_resource_deleted',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: session.resourcePath || '',
|
||||
currentDiskVersion: null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: 'external-editor',
|
||||
dirtyState: 'Deleted',
|
||||
message: '当前本地资源文件已被删除或移动,请恢复文件或关闭当前标签',
|
||||
suggestedActions: ['keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
|
||||
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const contentChanged = nextSerialized !== session.currentSerialized;
|
||||
session.externalChangePending = false;
|
||||
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
|
||||
if (!contentChanged) return;
|
||||
}
|
||||
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
|
||||
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
syncSessionMetaToViews(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) {
|
||||
session.lastExternalConflictEnvelope = {
|
||||
code: 'local_resource_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
path: session.resourcePath || '',
|
||||
currentDiskVersion: nextConflictKey || null,
|
||||
editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: 'external-editor',
|
||||
dirtyState: session.dirty ? 'Dirty' : 'Clean',
|
||||
message: '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突',
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff'],
|
||||
};
|
||||
markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message);
|
||||
return;
|
||||
}
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.fileVersion = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.lastUserInputAt = 0;
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-watch');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
syncResourceSessionTabGuards(session);
|
||||
};
|
||||
|
||||
const refreshSessionFromExternalFileChange = async (session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
|
||||
@@ -1045,12 +1293,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
if (!channel) {
|
||||
const url = new URL('/api/local-folder/events', window.location.origin);
|
||||
url.searchParams.set('rootUri', session.rootUri);
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
if (session.sessionKind === 'resource') {
|
||||
url.searchParams.set('resourcePath', resourceWatchPath(session));
|
||||
} else {
|
||||
url.searchParams.set('documentId', session.documentId);
|
||||
}
|
||||
const eventSource = new EventSource(url.toString());
|
||||
channel = {
|
||||
key: channelKey,
|
||||
rootUri: session.rootUri,
|
||||
documentId: session.documentId,
|
||||
resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '',
|
||||
eventSource,
|
||||
sessions: new Map(),
|
||||
};
|
||||
@@ -1060,6 +1313,19 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
Array.from(channel.sessions.values()).forEach((targetSession) => {
|
||||
if (!targetSession || targetSession.views.size === 0) return;
|
||||
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
|
||||
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
|
||||
if (targetSession.sessionKind === 'resource') {
|
||||
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
|
||||
if (targetSession.saving) {
|
||||
targetSession.externalChangePending = false;
|
||||
targetSession.lastSelfSaveSignalAt = Date.now();
|
||||
return;
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
|
||||
return;
|
||||
}
|
||||
if (!documentId) return;
|
||||
const eventKind = String(payload.eventKind || '');
|
||||
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
|
||||
@@ -1076,10 +1342,6 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
}
|
||||
targetSession.lastExternalChangeSignalAt = Date.now();
|
||||
targetSession.externalChangePending = true;
|
||||
if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) {
|
||||
markSessionExternalConflict(targetSession, externalConflictMessage);
|
||||
return;
|
||||
}
|
||||
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
|
||||
});
|
||||
});
|
||||
@@ -1277,11 +1539,67 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
return scheduled;
|
||||
};
|
||||
|
||||
const applyLocalUploadEditorSave = (detail) => {
|
||||
const documentId = String(detail?.documentId || '').trim();
|
||||
const rootUri = String(detail?.rootUri || '').trim();
|
||||
const fileVersion = String(detail?.fileVersion || '').trim();
|
||||
const writeIntentId = String(detail?.writeIntentId || '').trim();
|
||||
const saveOperationId = String(detail?.saveOperationId || '').trim();
|
||||
const serialized = String(detail?.serialized || '');
|
||||
if (!documentId || !rootUri || !fileVersion) return 0;
|
||||
let applied = 0;
|
||||
Array.from(documentSessionRegistry.values()).forEach((session) => {
|
||||
if (session.sourceKind !== 'local_folder') return;
|
||||
if (session.documentId !== documentId) return;
|
||||
if (String(session.rootUri || '').trim() !== rootUri) return;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
session.conflictDetectionKey = fileVersion;
|
||||
session.fileVersion = fileVersion;
|
||||
session.lastExternalConflictDetectionKey = fileVersion;
|
||||
if (writeIntentId) session.lastWriteIntentId = writeIntentId;
|
||||
if (saveOperationId) session.lastSaveOperationId = saveOperationId;
|
||||
if (session.latestAggregate && typeof session.latestAggregate === 'object') {
|
||||
const nextAggregate = { ...session.latestAggregate };
|
||||
const nextBody = nextAggregate.body && typeof nextAggregate.body === 'object'
|
||||
? { ...nextAggregate.body }
|
||||
: {};
|
||||
nextBody.fileVersion = fileVersion;
|
||||
nextBody.conflictDetectionKey = fileVersion;
|
||||
nextBody.conflict_detection_key = fileVersion;
|
||||
nextAggregate.body = nextBody;
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
}
|
||||
session.externalChangePending = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.lastExternalConflictEnvelope = null;
|
||||
session.bufferDirtyState = 'Clean';
|
||||
if (!serialized || serialized === session.currentSerialized) {
|
||||
session.lastPersistedSerialized = session.currentSerialized;
|
||||
session.dirty = false;
|
||||
setSessionStatus(session, 'saved');
|
||||
} else {
|
||||
session.dirty = true;
|
||||
setSessionStatus(session, 'dirty');
|
||||
queueSessionSave(session);
|
||||
}
|
||||
syncResourceSessionTabGuards(session);
|
||||
applied += 1;
|
||||
});
|
||||
return applied;
|
||||
};
|
||||
|
||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
||||
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
|
||||
});
|
||||
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
|
||||
applyLocalUploadEditorSave(event?.detail || {});
|
||||
});
|
||||
|
||||
const createDocumentSession = (runtimeDescriptor) => {
|
||||
const pageBody = runtimeDescriptor.aggregate.body || {};
|
||||
@@ -1291,6 +1609,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
|
||||
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
|
||||
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
|
||||
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
|
||||
const session = {
|
||||
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
|
||||
documentId: runtimeDescriptor.bootstrap.documentId,
|
||||
@@ -1301,6 +1620,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||
latestAggregate: runtimeDescriptor.aggregate,
|
||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||
pageBodySource,
|
||||
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
|
||||
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
currentSerialized: JSON.stringify(tiptapDocument),
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
@@ -1308,6 +1630,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
conflictDetectionKey,
|
||||
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
||||
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
|
||||
bufferDirtyState: 'Clean',
|
||||
readOnly: Boolean(permissions.readOnly),
|
||||
dirty: false,
|
||||
saving: false,
|
||||
@@ -1355,6 +1679,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
||||
markSessionExternalConflict,
|
||||
queueSessionSave,
|
||||
refreshSessionFromExternalChange,
|
||||
ensureLocalFolderEventChannel,
|
||||
releaseDocumentSession,
|
||||
scheduleDocumentSessionRelease,
|
||||
sessionHasRecentExternalSignal,
|
||||
|
||||
@@ -394,8 +394,18 @@ export const localizeTiptapAssetUrls = (node, context) => {
|
||||
return node;
|
||||
};
|
||||
|
||||
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||
export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
|
||||
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
|
||||
return 'local_markdown.content';
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
if (blockDocument) return 'page_aggregate.block_document';
|
||||
if (body?.content) return 'compat.legacy_content';
|
||||
return fallbackText ? 'degraded.fallback_text' : 'empty';
|
||||
};
|
||||
|
||||
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||
if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') {
|
||||
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
|
||||
}
|
||||
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||
|
||||
@@ -1,7 +1,122 @@
|
||||
// MNote 文件树上下文菜单运行时外置模块。
|
||||
// 当前先承接 CommandContext 构造与 when 表达式求值,树语义仍由 Rust/kernel 主导。
|
||||
|
||||
function commandContextResourceKindForRow(row) {
|
||||
if (!(row instanceof HTMLElement)) return 'unknown';
|
||||
var rowKind = String(row.getAttribute('data-row-kind') || '').trim();
|
||||
var assetType = String(row.getAttribute('data-asset-type') || '').trim();
|
||||
var assetId = String(row.getAttribute('data-asset-id') || '').trim();
|
||||
if (rowKind === 'folder' || rowKind === 'directory' || rowKind === 'asset-folder') return 'folder';
|
||||
if (rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') return 'page';
|
||||
if (assetType) return assetType;
|
||||
if (assetId) return 'asset';
|
||||
return rowKind || 'unknown';
|
||||
}
|
||||
|
||||
function commandContextDirtyState(value) {
|
||||
var state = String(value || '').trim();
|
||||
if (!state) return 'clean';
|
||||
var lowered = state.toLowerCase();
|
||||
if (lowered === 'dirty' || lowered === 'stale' || lowered === 'deleted') return lowered;
|
||||
if (lowered === 'externalmodified' || lowered === 'external_modified' || lowered === 'external-change-conflict') return 'external_modified';
|
||||
if (lowered === 'hasexternalconflict' || lowered === 'has_external_conflict') return 'has_external_conflict';
|
||||
return lowered === 'clean' || lowered === 'saved' ? 'clean' : lowered;
|
||||
}
|
||||
|
||||
function commandContextIsDirtyState(value) {
|
||||
return commandContextDirtyState(value) !== 'clean';
|
||||
}
|
||||
|
||||
function commandContextEditorMatchesRow(editor, row) {
|
||||
if (!editor || !(row instanceof HTMLElement)) return false;
|
||||
var rowDocumentId = String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim();
|
||||
if (rowDocumentId && String(editor.documentId || '').trim() === rowDocumentId) return true;
|
||||
var rowAssetId = String(row.getAttribute('data-asset-id') || '').trim();
|
||||
if (rowAssetId && String(editor.assetId || editor.workspacePath && editor.workspacePath.assetId || '').trim() === rowAssetId) return true;
|
||||
var rowRelativePath = String(row.getAttribute('data-local-relative-path') || '').trim();
|
||||
var editorRelativePath = String(editor.workspacePath && (editor.workspacePath.relativePath || editor.workspacePath.localRelativePath) || '').trim();
|
||||
return Boolean(rowRelativePath && editorRelativePath && rowRelativePath === editorRelativePath);
|
||||
}
|
||||
|
||||
function commandContextDirtyStateForRows(rows, openEditorsSnapshot, bufferState) {
|
||||
if (bufferState && typeof bufferState === 'object') {
|
||||
var directState = bufferState.dirtyState || bufferState.state || bufferState.status || '';
|
||||
if (commandContextIsDirtyState(directState)) return commandContextDirtyState(directState);
|
||||
}
|
||||
var editors = [];
|
||||
if (openEditorsSnapshot && Array.isArray(openEditorsSnapshot.editors)) editors = editors.concat(openEditorsSnapshot.editors);
|
||||
if (openEditorsSnapshot && Array.isArray(openEditorsSnapshot.resourceEditors)) editors = editors.concat(openEditorsSnapshot.resourceEditors);
|
||||
for (var r = 0; r < rows.length; r += 1) {
|
||||
var row = rows[r];
|
||||
for (var i = 0; i < editors.length; i += 1) {
|
||||
var editor = editors[i];
|
||||
if (!commandContextEditorMatchesRow(editor, row)) continue;
|
||||
var editorState = editor.dirtyState || editor.dirtyGuard || '';
|
||||
if (commandContextIsDirtyState(editorState)) return commandContextDirtyState(editorState);
|
||||
}
|
||||
}
|
||||
return 'clean';
|
||||
}
|
||||
|
||||
function selectedRowsFromDeps(deps, fallbackRow) {
|
||||
var selection = deps && deps.selection ? deps.selection : {};
|
||||
var rowIds = Array.isArray(selection.selectedRowIds)
|
||||
? selection.selectedRowIds
|
||||
: Array.from(selection.selectedRowIds || []);
|
||||
var rows = [];
|
||||
var queryRowById = deps && typeof deps.queryRowById === 'function'
|
||||
? deps.queryRowById
|
||||
: function() { return null; };
|
||||
for (var i = 0; i < rowIds.length; i += 1) {
|
||||
var row = queryRowById(rowIds[i]);
|
||||
if (row instanceof HTMLElement) rows.push(row);
|
||||
}
|
||||
if (!rows.length && fallbackRow instanceof HTMLElement) rows.push(fallbackRow);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildFileTreeCommandContext(row, openEditorsSnapshot, bufferState, deps) {
|
||||
deps = deps || {};
|
||||
var rows = selectedRowsFromDeps(deps, row);
|
||||
var resourceKinds = {};
|
||||
for (var j = 0; j < rows.length; j += 1) {
|
||||
resourceKinds[commandContextResourceKindForRow(rows[j])] = true;
|
||||
}
|
||||
var rkKeys = Object.keys(resourceKinds);
|
||||
var currentSourceKind = typeof deps.currentSourceKind === 'function'
|
||||
? deps.currentSourceKind
|
||||
: function() { return ''; };
|
||||
var readonly = typeof deps.workspaceReadonly === 'function'
|
||||
? deps.workspaceReadonly()
|
||||
: false;
|
||||
var dirtyState = commandContextDirtyStateForRows(rows, openEditorsSnapshot, bufferState);
|
||||
var editorDirty = commandContextIsDirtyState(dirtyState);
|
||||
var targetKind = commandContextResourceKindForRow(row);
|
||||
return {
|
||||
'workspace.sourceKind': currentSourceKind() || '',
|
||||
'workspace.readonly': readonly === true,
|
||||
'tree.focusKind': deps.focusKind || 'file_tree',
|
||||
'tree.selectionCount': rows.length,
|
||||
'tree.selectionResourceKind': rkKeys.length === 1 ? rkKeys[0] : rows.length ? 'mixed' : targetKind,
|
||||
'tree.targetResourceKind': targetKind,
|
||||
'tree.targetRowKind': row instanceof HTMLElement ? String(row.getAttribute('data-row-kind') || '').trim() : '',
|
||||
'tree.targetIsFolder': targetKind === 'folder',
|
||||
'tree.targetIsAsset': Boolean(row instanceof HTMLElement && String(row.getAttribute('data-asset-id') || '').trim()),
|
||||
'editor.dirty': editorDirty,
|
||||
'editor.dirtyState': dirtyState,
|
||||
'editor.hasSelection': Boolean(deps.editorHasSelection),
|
||||
'ai.canWrite': readonly !== true && !editorDirty,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSidebarFileTreeContext(kind, deps) {
|
||||
var targetRow = deps && deps.targetRow instanceof HTMLElement ? deps.targetRow : null;
|
||||
return buildFileTreeCommandContext(targetRow, deps && deps.openEditorsSnapshot, deps && deps.bufferState, Object.assign({}, deps || {}, {
|
||||
focusKind: kind === 'filetree' ? 'file_tree' : kind === 'page' ? 'page_tree' : kind,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildLegacySidebarFileTreeContext(kind, deps) {
|
||||
var selection = deps && deps.selection ? deps.selection : {};
|
||||
var rowIds = Array.isArray(selection.selectedRowIds)
|
||||
? selection.selectedRowIds
|
||||
@@ -181,7 +296,9 @@ function tokenizeWhenExpression(expr) {
|
||||
}
|
||||
|
||||
window.__mnoteFileTreeContextMenuRuntime = {
|
||||
buildFileTreeCommandContext: buildFileTreeCommandContext,
|
||||
buildSidebarFileTreeContext: buildSidebarFileTreeContext,
|
||||
buildLegacySidebarFileTreeContext: buildLegacySidebarFileTreeContext,
|
||||
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
|
||||
tokenizeWhenExpression: tokenizeWhenExpression
|
||||
};
|
||||
|
||||
@@ -42,6 +42,10 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
|
||||
if (event.key === 'F2') {
|
||||
event.preventDefault();
|
||||
var renameCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')) {
|
||||
return true;
|
||||
}
|
||||
beginFileTreeInlineRename(fileTreeRowForKey);
|
||||
return true;
|
||||
}
|
||||
@@ -61,6 +65,10 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
}
|
||||
if (shortcutKey === 'v') {
|
||||
event.preventDefault();
|
||||
var pasteCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(pasteCtx, '!workspace.readonly')) {
|
||||
return true;
|
||||
}
|
||||
if (typeof deps.pasteSidebarFileTreeClipboard === 'function') {
|
||||
void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey);
|
||||
}
|
||||
@@ -70,8 +78,8 @@ function handleFileTreeKeyDown(event, deps) {
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
var delCtx = buildSidebarFileTreeContext('filetree');
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
|
||||
var delCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')) {
|
||||
return true;
|
||||
}
|
||||
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
|
||||
|
||||
@@ -70,6 +70,57 @@ function fileTreeRowLocalUploadTargetRelativePath(row, deps) {
|
||||
return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : '';
|
||||
}
|
||||
|
||||
function parseObjectIdentityAttr(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed : { raw: raw };
|
||||
} catch (_) {
|
||||
return { raw: raw };
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkspacePathFromRow(row, deps) {
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
deps = deps || {};
|
||||
var currentSourceKind = typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; };
|
||||
var currentRootUri = typeof deps.currentRootUri === 'function' ? deps.currentRootUri : function() { return ''; };
|
||||
var resolveWorkspaceId = typeof deps.resolveWorkspaceId === 'function' ? deps.resolveWorkspaceId : function() { return ''; };
|
||||
var rowTitle = typeof deps.rowTitle === 'function' ? deps.rowTitle : function(target) {
|
||||
return String(target && target.textContent || '').trim();
|
||||
};
|
||||
var objectIdentityRaw = String(row.getAttribute('data-object-identity') || '').trim();
|
||||
var objectIdentity = parseObjectIdentityAttr(objectIdentityRaw);
|
||||
var objectKind = String(row.getAttribute('data-object-kind') || (objectIdentity && objectIdentity.objectKind) || '').trim();
|
||||
var relativePath = fileTreeRowLocalRelativePath(row, deps);
|
||||
var assetId = fileTreeRowAssetId(row, deps);
|
||||
var documentId = fileTreeRowDocumentId(row);
|
||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||
var rowKind = fileTreeRowKind(row);
|
||||
var sourceKind = String(row.getAttribute('data-source-kind') || currentSourceKind() || '').trim();
|
||||
var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim();
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: String(row.getAttribute('data-workspace-id') || resolveWorkspaceId(row) || '').trim(),
|
||||
sourceKind: sourceKind,
|
||||
rootUri: rootUri,
|
||||
relativePath: relativePath,
|
||||
localRelativePath: relativePath,
|
||||
objectIdentity: objectIdentity,
|
||||
objectIdentityRaw: objectIdentityRaw,
|
||||
objectKind: objectKind,
|
||||
resourceKind: objectKind || rowKind,
|
||||
rowId: rowId,
|
||||
rowKind: rowKind,
|
||||
documentId: documentId,
|
||||
assetId: assetId,
|
||||
title: rowTitle(row),
|
||||
href: String(row.querySelector?.('.tree-link')?.getAttribute?.('href') || '').trim(),
|
||||
isLocalFolder: sourceKind === 'local_folder' || (!sourceKind && (rowId.indexOf('local:') === 0 || documentId.indexOf('local-md:') === 0 || documentId.indexOf('local-dir:') === 0))
|
||||
};
|
||||
}
|
||||
|
||||
function isFileTreeDownloadableAssetRow(row, deps) {
|
||||
if (!(row instanceof HTMLElement)) return false;
|
||||
var kind = fileTreeRowKind(row);
|
||||
@@ -98,6 +149,7 @@ function fileTreeAssetDownloadDetail(row, deps) {
|
||||
rowKind: fileTreeRowKind(row),
|
||||
assetId: assetId,
|
||||
localRelativePath: relativePath,
|
||||
workspacePath: readWorkspacePathFromRow(row, deps),
|
||||
title: title,
|
||||
fileName: title,
|
||||
workspaceId: resolveWorkspaceId(row)
|
||||
@@ -274,6 +326,49 @@ function revealFileTreeAssetRow(assetId, deps) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function localUploadedAssetRelativePath(asset, deps) {
|
||||
deps = deps || {};
|
||||
var direct = String(asset && (asset.rootRelativePath || asset.root_relative_path || asset.relativePath || asset.relative_path) || '').trim();
|
||||
if (direct) return direct;
|
||||
var localFilePathFromAssetId = typeof deps.localFilePathFromAssetId === 'function'
|
||||
? deps.localFilePathFromAssetId
|
||||
: function(assetId) {
|
||||
var value = String(assetId || '').trim();
|
||||
return value.indexOf('local-file:') === 0
|
||||
? value.slice('local-file:'.length)
|
||||
: value.indexOf('local:asset:') === 0
|
||||
? value.slice('local:asset:'.length)
|
||||
: '';
|
||||
};
|
||||
return localFilePathFromAssetId(String(asset && asset.id || '').trim());
|
||||
}
|
||||
|
||||
function parentRelativePathForUploadedAsset(asset, relativePath) {
|
||||
var target = String(asset && (asset.targetRelativePath || asset.target_relative_path) || '').trim();
|
||||
if (target || Object.prototype.hasOwnProperty.call(asset || {}, 'targetRelativePath')) return target;
|
||||
var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
var lastSlash = normalized.lastIndexOf('/');
|
||||
return lastSlash >= 0 ? normalized.slice(0, lastSlash) : '';
|
||||
}
|
||||
|
||||
function findLocalFileTreeParentRowForAsset(asset, relativePath, deps) {
|
||||
deps = deps || {};
|
||||
var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); };
|
||||
var currentSourceKind = typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; };
|
||||
if (currentSourceKind() !== 'local_folder') return null;
|
||||
var parentRelativePath = parentRelativePathForUploadedAsset(asset, relativePath);
|
||||
var selector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(parentRelativePath) + '"]';
|
||||
if (parentRelativePath) {
|
||||
var parentRow = document.querySelector(selector);
|
||||
if (parentRow instanceof HTMLElement) return parentRow;
|
||||
}
|
||||
if (!parentRelativePath) {
|
||||
var root = document.querySelector('#sidebar-file-tree-root .tree-root');
|
||||
return root instanceof HTMLElement ? null : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendUploadedAssetRow(asset, documentId, deps) {
|
||||
deps = deps || {};
|
||||
var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); };
|
||||
@@ -295,8 +390,10 @@ function appendUploadedAssetRow(asset, documentId, deps) {
|
||||
if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) {
|
||||
objectKind = 'mindmap';
|
||||
}
|
||||
var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim();
|
||||
var parentRow = targetDocumentId
|
||||
var localRelativePath = localUploadedAssetRelativePath(asset, deps);
|
||||
var targetDocumentId = String(documentId || asset.document_id || asset.documentId || asset.ownerDocumentId || currentDocumentId() || '').trim();
|
||||
var parentRow = findLocalFileTreeParentRowForAsset(asset, localRelativePath, deps);
|
||||
if (!parentRow) parentRow = targetDocumentId
|
||||
? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]')
|
||||
: null;
|
||||
if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;
|
||||
@@ -325,16 +422,20 @@ function appendUploadedAssetRow(asset, documentId, deps) {
|
||||
objectKind: objectKind || 'attachment',
|
||||
documentId: targetDocumentId || null,
|
||||
blockId: null,
|
||||
assetId: assetId
|
||||
assetId: assetId,
|
||||
rootUri: String(asset && (asset.rootUri || asset.root_uri) || '').trim() || null,
|
||||
relativePath: localRelativePath || null
|
||||
};
|
||||
li.setAttribute('data-node-id', 'asset:' + assetId);
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var iconKind = fileTreeIconKindForFileName(title) || uploadedAssetType(asset) || 'file';
|
||||
if (objectKind === 'mindmap') iconKind = 'mindmap';
|
||||
var ownerDocumentId = String(asset && (asset.ownerDocumentId || asset.owner_document_id) || targetDocumentId || '').trim();
|
||||
var rootUri = String(asset && (asset.rootUri || asset.root_uri) || '').trim();
|
||||
li.innerHTML =
|
||||
'<div class="tree-row" role="treeitem" aria-level="' + (parentRow ? '2' : '1') + '" aria-expanded="false" data-rust-rendered-row="filetree" data-testid="filetree-asset-row" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-row-kind="asset" data-node-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-doc-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(objectKind || 'attachment') + '" data-shell-mode="filetree" data-selected="false" data-active="false" draggable="true">' +
|
||||
'<div class="tree-row" role="treeitem" aria-level="' + (parentRow ? '2' : '1') + '" aria-expanded="false" data-rust-rendered-row="filetree" data-testid="filetree-asset-row" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-row-kind="asset" data-node-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-doc-id="' + escapeHtml(targetDocumentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(localRelativePath) + '" data-root-uri="' + escapeHtml(rootUri) + '" data-source-kind="' + escapeHtml(rootUri ? 'local_folder' : '') + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(objectKind || 'attachment') + '" data-shell-mode="filetree" data-selected="false" data-active="false" draggable="true">' +
|
||||
'<span class="tree-spacer" aria-hidden="true"></span><span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span>' +
|
||||
'<button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button>' +
|
||||
'<button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml('asset:' + assetId) + '" data-document-id="' + escapeHtml(targetDocumentId) + '" data-owner-document-id="' + escapeHtml(ownerDocumentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(localRelativePath) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button>' +
|
||||
'<div class="tree-actions"><button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml('asset:' + assetId) + '" aria-label="更多操作">…</button></div></div>';
|
||||
container.appendChild(li);
|
||||
revealFileTreeRow(li.querySelector('.tree-row'));
|
||||
@@ -584,6 +685,7 @@ window.__mnoteFileTreeRuntime = {
|
||||
decodeLocalEncodedPath: decodeLocalEncodedPath,
|
||||
fileTreeRowLocalRelativePath: fileTreeRowLocalRelativePath,
|
||||
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
|
||||
readWorkspacePathFromRow: readWorkspacePathFromRow,
|
||||
isFileTreeDownloadableAssetRow: isFileTreeDownloadableAssetRow,
|
||||
isFileTreeDownloadableRow: isFileTreeDownloadableRow,
|
||||
fileTreeAssetDownloadDetail: fileTreeAssetDownloadDetail,
|
||||
|
||||
@@ -55,15 +55,91 @@ function dispatchUploadedEditorChange(editorRoot, editor, deps) {
|
||||
}));
|
||||
}
|
||||
|
||||
function currentConflictDetectionKey() {
|
||||
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
|
||||
if (!script) return '';
|
||||
try {
|
||||
var body = JSON.parse(script.textContent || 'null');
|
||||
return String(body && (body.conflictDetectionKey || body.conflict_detection_key || body.fileVersion || body.file_version) || '').trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
function firstNonEmptyString(values) {
|
||||
for (var i = 0; i < values.length; i += 1) {
|
||||
var value = String(values[i] || '').trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function makeSaveOperationToken(prefix, documentId) {
|
||||
var doc = String(documentId || 'unknown').replace(/[^a-zA-Z0-9._:-]+/g, '-').slice(0, 80);
|
||||
var random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
return prefix + ':' + doc + ':' + random;
|
||||
}
|
||||
|
||||
function currentConflictDetectionKey(documentId, rootUri) {
|
||||
try {
|
||||
if (typeof window.__mnoteDebugDocumentSessions?.snapshot === 'function') {
|
||||
var snapshot = window.__mnoteDebugDocumentSessions.snapshot();
|
||||
var sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
|
||||
var targetDocumentId = String(documentId || '').trim();
|
||||
var targetRootUri = String(rootUri || '').trim();
|
||||
for (var i = 0; i < sessions.length; i += 1) {
|
||||
var session = sessions[i] || {};
|
||||
if (targetDocumentId && String(session.documentId || '').trim() !== targetDocumentId) continue;
|
||||
if (targetRootUri && String(session.rootUri || '').trim() !== targetRootUri) continue;
|
||||
var sessionKey = firstNonEmptyString([
|
||||
session.lastExternalConflictDetectionKey,
|
||||
session.conflictDetectionKey,
|
||||
session.fileVersion
|
||||
]);
|
||||
if (sessionKey) return sessionKey;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 继续尝试从 page aggregate script 读取版本。
|
||||
}
|
||||
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__');
|
||||
if (script) {
|
||||
try {
|
||||
var aggregate = JSON.parse(script.textContent || 'null');
|
||||
var body = aggregate && typeof aggregate.body === 'object' ? aggregate.body : null;
|
||||
var key = firstNonEmptyString([
|
||||
body && body.conflictDetectionKey,
|
||||
body && body.conflict_detection_key,
|
||||
body && body.fileVersion,
|
||||
body && body.file_version,
|
||||
aggregate && aggregate.conflictDetectionKey,
|
||||
aggregate && aggregate.conflict_detection_key,
|
||||
aggregate && aggregate.fileVersion,
|
||||
aggregate && aggregate.file_version
|
||||
]);
|
||||
if (key) return key;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function localUploadSessionConflict(documentId, rootUri) {
|
||||
try {
|
||||
if (typeof window.__mnoteDebugDocumentSessions?.snapshot !== 'function') return null;
|
||||
var snapshot = window.__mnoteDebugDocumentSessions.snapshot();
|
||||
var sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
|
||||
var targetDocumentId = String(documentId || '').trim();
|
||||
var targetRootUri = String(rootUri || '').trim();
|
||||
for (var i = 0; i < sessions.length; i += 1) {
|
||||
var session = sessions[i] || {};
|
||||
if (targetDocumentId && String(session.documentId || '').trim() !== targetDocumentId) continue;
|
||||
if (targetRootUri && String(session.rootUri || '').trim() !== targetRootUri) continue;
|
||||
var status = String(session.status || '').trim();
|
||||
if (status === 'external-change-conflict' || session.hasExternalConflict === true || session.lastExternalConflictEnvelope) {
|
||||
return {
|
||||
status: status || 'external-change-conflict',
|
||||
dirtyState: String(session.dirtyState || ''),
|
||||
message: String(session.lastExternalConflictEnvelope?.message || '当前文档存在文件冲突,请先处理冲突后再上传附件')
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) {
|
||||
@@ -101,7 +177,9 @@ async function persistUploadedEditorChange(editor, asset, deps) {
|
||||
documentId: documentId,
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
expectedFileVersion: currentConflictDetectionKey(),
|
||||
expectedFileVersion: currentConflictDetectionKey(documentId, rootUri),
|
||||
writeIntentId: makeSaveOperationToken('intent:local-upload', documentId),
|
||||
saveOperationId: makeSaveOperationToken('save:local-upload', documentId),
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'local-upload-runtime',
|
||||
editorDocument: editorDocument,
|
||||
@@ -120,10 +198,32 @@ async function persistUploadedEditorChange(editor, asset, deps) {
|
||||
if (!response.ok || !result || result.ok !== true) {
|
||||
var message = result && (result.message || (result.error && result.error.message)) || ('save_failed_' + response.status);
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-save-error', message);
|
||||
if (response.status === 409 && asset && asset.id) {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-orphaned-asset-id', String(asset.id || ''));
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-orphaned-policy', 'asset-kept-reference-unsaved-retry-required');
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
var saved = result.result && typeof result.result === 'object' ? result.result : {};
|
||||
var fileVersion = String(saved.fileVersion || saved.conflictDetectionKey || saved.conflict_detection_key || '').trim();
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('mnote:local-upload-editor-save-completed', {
|
||||
detail: {
|
||||
documentId: documentId,
|
||||
rootUri: rootUri,
|
||||
fileVersion: fileVersion,
|
||||
writeIntentId: String(saved.writeIntentId || savePayload.writeIntentId || '').trim(),
|
||||
saveOperationId: String(saved.saveOperationId || savePayload.saveOperationId || '').trim(),
|
||||
revision: Number.isInteger(saved.revision) ? saved.revision : null,
|
||||
serialized: JSON.stringify(tiptapDocument),
|
||||
source: 'local-upload-runtime'
|
||||
}
|
||||
}));
|
||||
} catch (_) {}
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-saved', 'true');
|
||||
document.documentElement.removeAttribute('data-mnote-last-upload-save-error');
|
||||
document.documentElement.removeAttribute('data-mnote-last-upload-orphaned-asset-id');
|
||||
document.documentElement.removeAttribute('data-mnote-last-upload-orphaned-policy');
|
||||
}
|
||||
|
||||
function currentRootUri() {
|
||||
@@ -391,7 +491,7 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed');
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
window.setTimeout(function annotateUploadedLink(attempt) {
|
||||
if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
|
||||
if (typeof deps.enhanceEditorAttachmentLinks === 'function') deps.enhanceEditorAttachmentLinks();
|
||||
var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value || '').replace(/"/g, '\\"'); };
|
||||
@@ -401,12 +501,22 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
|
||||
var link = targetRoot instanceof HTMLElement
|
||||
? targetRoot.querySelector(selector)
|
||||
: document.querySelector(selector);
|
||||
if (!(link instanceof HTMLElement) && assetId) {
|
||||
var scope = targetRoot instanceof HTMLElement ? targetRoot : document;
|
||||
link = Array.from(scope.querySelectorAll('.editor-surface .ProseMirror a')).find(function(candidate) {
|
||||
var href = String(candidate.getAttribute('href') || '');
|
||||
try { href = decodeURIComponent(href); } catch (_) {}
|
||||
return href.indexOf(assetId) >= 0;
|
||||
}) || null;
|
||||
}
|
||||
if (link instanceof HTMLElement) {
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
||||
} else if (Number(attempt) < 20) {
|
||||
window.setTimeout(function() { annotateUploadedLink(Number(attempt) + 1); }, 50);
|
||||
}
|
||||
}, 0);
|
||||
}, 0, 0);
|
||||
return inserted;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -434,6 +544,18 @@ async function uploadFileToMediaAsset(file, plan, options, deps) {
|
||||
if (getCurrentSourceKind() === 'local_folder') {
|
||||
var rootUri = getCurrentRootUri();
|
||||
var documentId = String(plan && plan.targetDocumentId || getCurrentDocumentId() || '').trim();
|
||||
if (String(plan && plan.uploadIntent || '').trim() === 'editor.markdown.attach' && documentId) {
|
||||
var conflict = localUploadSessionConflict(documentId, rootUri);
|
||||
if (conflict) {
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-blocked-conflict', 'true');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-save-error', conflict.message || '当前文档存在文件冲突,请先处理冲突后再上传附件');
|
||||
throw new Error(conflict.message || '当前文档存在文件冲突,请先处理冲突后再上传附件');
|
||||
}
|
||||
document.documentElement.removeAttribute('data-mnote-last-upload-blocked-conflict');
|
||||
}
|
||||
if (String(plan && plan.uploadIntent || '').trim() === 'editor.markdown.attach' && documentId) {
|
||||
persistLocalFolderSelfChangeSuppression(documentId, Date.now() + 8000);
|
||||
}
|
||||
var localResult = await uploadLocalFolderAsset(file, plan, {
|
||||
rootUri: rootUri,
|
||||
documentId: documentId,
|
||||
@@ -446,7 +568,7 @@ async function uploadFileToMediaAsset(file, plan, options, deps) {
|
||||
if (options.insertIntoEditor) {
|
||||
await insertIntoEditor(localAsset, resolveEditorRoot(options));
|
||||
}
|
||||
if (typeof deps.refreshLocalFolderSidebarSnapshot === 'function') {
|
||||
if (!options.insertIntoEditor && typeof deps.refreshLocalFolderSidebarSnapshot === 'function') {
|
||||
void deps.refreshLocalFolderSidebarSnapshot();
|
||||
}
|
||||
dispatch(new EventCtor('wolai:local-assets-changed', {
|
||||
@@ -528,6 +650,9 @@ function fileTreeIconKindForFileName(fileName) {
|
||||
if (ext === 'md' || ext === 'markdown') return 'markdown';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image';
|
||||
if (['html', 'htm', 'css', 'scss', 'less', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'vue', 'svelte', 'astro'].indexOf(ext) >= 0) return 'web';
|
||||
if (['json', 'jsonc', 'json5', 'toml', 'yaml', 'yml', 'ini', 'env', 'xml', 'lock', 'hcl', 'tf', 'tfvars', 'nix', 'properties', 'conf', 'cfg', 'config', 'service', 'desktop', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'prettierrc', 'eslintrc'].indexOf(ext) >= 0 || ['.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc', 'dockerfile', 'containerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile'].indexOf(name) >= 0) return 'config';
|
||||
if (['rs', 'py', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'rb', 'sh', 'bash', 'zsh', 'sql', 'lua', 'dart', 'kt', 'kts', 'swift', 'scala', 'gradle', 'groovy', 'clj', 'ex', 'exs', 'erl', 'hrl', 'fs', 'fsx', 'r', 'jl', 'm', 'mm', 'pl', 'pm', 'ps1', 'bat', 'cmd', 'psm1', 'psd1', 'proto', 'graphql', 'gql', 'prisma', 'cmake', 'bazel', 'bzl'].indexOf(ext) >= 0) return 'code';
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,50 @@ function buildLocalFileOpenUrl(relativePath, download) {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildPdfPreviewOpenUrl(fileUrl, fileName) {
|
||||
var rawUrl = String(fileUrl || '').trim();
|
||||
if (!rawUrl) return '';
|
||||
var url = new URL('/pdf-preview', window.location.origin);
|
||||
url.searchParams.set('fileUrl', rawUrl);
|
||||
if (fileName) url.searchParams.set('fileName', fileName);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function shouldUseOfficePreview(fileType, mode) {
|
||||
var normalizedMode = String(mode || 'view').trim().toLowerCase();
|
||||
if (normalizedMode === 'edit') return false;
|
||||
var ext = String(fileType || '').trim().toLowerCase();
|
||||
return ['docx', 'xlsx', 'xls', 'csv', 'pptx'].indexOf(ext) >= 0;
|
||||
}
|
||||
|
||||
function buildOfficePreviewOpenUrl(input) {
|
||||
var target = new URL('/office-preview', window.location.origin);
|
||||
var workspacePath = input && input.workspacePath && typeof input.workspacePath === 'object' ? input.workspacePath : {};
|
||||
var fileUrl = String(input.fileUrl || '').trim();
|
||||
if (fileUrl) {
|
||||
try {
|
||||
fileUrl = new URL(fileUrl, window.location.origin).toString();
|
||||
} catch (_error) {}
|
||||
}
|
||||
target.searchParams.set('fileUrl', fileUrl);
|
||||
target.searchParams.set('fileName', input.fileName || '未命名资源');
|
||||
target.searchParams.set('fileType', input.fileType || 'docx');
|
||||
if (input.assetId) target.searchParams.set('assetId', input.assetId);
|
||||
if (input.documentId) target.searchParams.set('documentId', input.documentId);
|
||||
if (input.userId) target.searchParams.set('userId', input.userId);
|
||||
var workspaceId = String(input.workspaceId || workspacePath.workspaceId || '').trim();
|
||||
var sourceKind = String(input.sourceKind || workspacePath.sourceKind || currentSourceKind() || '').trim();
|
||||
var rootUri = String(input.rootUri || workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
if (workspaceId) target.searchParams.set('workspaceId', workspaceId);
|
||||
if (sourceKind) target.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri) target.searchParams.set('rootUri', rootUri);
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function buildOnlyOfficeOpenPath(input) {
|
||||
if (shouldUseOfficePreview(input.fileType, input.mode)) {
|
||||
return buildOfficePreviewOpenUrl(input);
|
||||
}
|
||||
var params = new URLSearchParams();
|
||||
params.set('fileUrl', input.fileUrl || '');
|
||||
params.set('fileName', input.fileName || '未命名资源');
|
||||
@@ -47,8 +90,19 @@ function currentRootUri() {
|
||||
: '';
|
||||
}
|
||||
|
||||
function currentSourceKind() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = (params.get('sourceKind') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
return document.body instanceof HTMLElement
|
||||
? (document.body.getAttribute('data-mnote-source-kind') || '').trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
window.__mnoteResourceOpenRuntime = {
|
||||
localFilePathFromAssetId: localFilePathFromAssetId,
|
||||
buildLocalFileOpenUrl: buildLocalFileOpenUrl,
|
||||
buildPdfPreviewOpenUrl: buildPdfPreviewOpenUrl,
|
||||
buildOfficePreviewOpenUrl: buildOfficePreviewOpenUrl,
|
||||
buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath
|
||||
};
|
||||
|
||||
@@ -5,12 +5,14 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
buildLocalOnlyOfficeOpenUrl,
|
||||
buildOnlyOfficeOpenPath,
|
||||
buildOnlyOfficeOpenUrl,
|
||||
buildPdfPreviewOpenUrl: injectedBuildPdfPreviewOpenUrl,
|
||||
closestAction: injectedClosestAction,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentWorkspaceSourcePayload,
|
||||
fileTreeIconKindForFileName,
|
||||
healLegacyOfficeAttachmentParagraphs,
|
||||
hydrateEditorAttachmentMeta: injectedHydrateEditorAttachmentMeta,
|
||||
inferCodeAttachmentLanguage,
|
||||
inferOnlyOfficeFileType,
|
||||
isCodeAttachmentFileName,
|
||||
@@ -28,9 +30,15 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
|
||||
return node && typeof node.closest === 'function' ? node.closest(selector) : null;
|
||||
};
|
||||
const hydrateEditorAttachmentMeta = typeof injectedHydrateEditorAttachmentMeta === 'function'
|
||||
? injectedHydrateEditorAttachmentMeta
|
||||
: async function() {};
|
||||
|
||||
var activeEditorAttachmentLink = null;
|
||||
var attachmentActionsHideTimer = 0;
|
||||
var editorAttachmentMissingByKey = new Map();
|
||||
var editorAttachmentRefreshSeqByKey = new Map();
|
||||
var editorAttachmentLinkSelector = '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]';
|
||||
|
||||
function attachmentQueryParams(href) {
|
||||
try {
|
||||
@@ -60,6 +68,13 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
function localFileOpenKeyFromHref(href) {
|
||||
var localFilePath = localFileOpenPathFromHref(href);
|
||||
if (!localFilePath) return '';
|
||||
var rootUri = localFileOpenRootUriFromHref(href) || currentRootUri() || '';
|
||||
return String(rootUri || '').trim() + '\n' + localFilePath;
|
||||
}
|
||||
|
||||
function buildLocalFileStatusUrl(relativePath, rootUri) {
|
||||
var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
|
||||
if (!effectiveRootUri || !relativePath) return '';
|
||||
@@ -69,13 +84,26 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildPdfPreviewOpenUrl(fileUrl, fileName) {
|
||||
if (typeof injectedBuildPdfPreviewOpenUrl === 'function') {
|
||||
return injectedBuildPdfPreviewOpenUrl(fileUrl, fileName);
|
||||
}
|
||||
var rawUrl = String(fileUrl || '').trim();
|
||||
if (!rawUrl) return '';
|
||||
var url = new URL('/pdf-preview', window.location.origin);
|
||||
url.searchParams.set('fileUrl', rawUrl);
|
||||
if (fileName) url.searchParams.set('fileName', fileName);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setEditorAttachmentMissingState(link, missing) {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var value = Boolean(missing);
|
||||
if (value) {
|
||||
link.setAttribute('data-mnote-attachment-missing', 'true');
|
||||
if (link.getAttribute('data-mnote-attachment-missing') === 'true' && link.classList.contains('mnote-uploaded-attachment-missing')) return;
|
||||
setAttributeIfChanged(link, 'data-mnote-attachment-missing', 'true');
|
||||
link.classList.add('mnote-uploaded-attachment-missing');
|
||||
link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)');
|
||||
setAttributeIfChanged(link, 'aria-label', (link.textContent || '附件') + '(文件不存在)');
|
||||
} else {
|
||||
if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return;
|
||||
link.removeAttribute('data-mnote-attachment-missing');
|
||||
@@ -84,9 +112,35 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
function setAttributeIfChanged(element, name, value) {
|
||||
var nextValue = String(value || '');
|
||||
if (element.getAttribute(name) === nextValue) return;
|
||||
element.setAttribute(name, nextValue);
|
||||
}
|
||||
|
||||
function setEditorAttachmentMissingStateByHref(href, missing) {
|
||||
var targetKey = localFileOpenKeyFromHref(href);
|
||||
if (!targetKey) return;
|
||||
if (missing) {
|
||||
editorAttachmentMissingByKey.set(targetKey, true);
|
||||
} else {
|
||||
editorAttachmentMissingByKey.delete(targetKey);
|
||||
}
|
||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
||||
if (!(candidate instanceof HTMLAnchorElement)) return;
|
||||
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
||||
if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return;
|
||||
setEditorAttachmentMissingState(candidate, missing);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshLocalAttachmentExistence(link) {
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var href = link.getAttribute('href') || link.href || '';
|
||||
var attachmentKey = localFileOpenKeyFromHref(href);
|
||||
if (!attachmentKey) return;
|
||||
var refreshSeq = (editorAttachmentRefreshSeqByKey.get(attachmentKey) || 0) + 1;
|
||||
editorAttachmentRefreshSeqByKey.set(attachmentKey, refreshSeq);
|
||||
var localFilePath = localFileOpenPathFromHref(href);
|
||||
if (!localFilePath) return;
|
||||
var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
|
||||
@@ -94,11 +148,32 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
try {
|
||||
var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (editorAttachmentRefreshSeqByKey.get(attachmentKey) !== refreshSeq) return;
|
||||
if (!response.ok || !payload || payload.ok !== true || !payload.result) {
|
||||
setEditorAttachmentStatusErrorByHref(href, String(response.status || 'stat_failed'));
|
||||
return;
|
||||
}
|
||||
var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
|
||||
setEditorAttachmentMissingState(link, !exists);
|
||||
setEditorAttachmentStatusErrorByHref(href, '');
|
||||
setEditorAttachmentMissingStateByHref(href, !exists);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function setEditorAttachmentStatusErrorByHref(href, status) {
|
||||
var targetKey = localFileOpenKeyFromHref(href);
|
||||
if (!targetKey) return;
|
||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) {
|
||||
if (!(candidate instanceof HTMLAnchorElement)) return;
|
||||
var candidateHref = candidate.getAttribute('href') || candidate.href || '';
|
||||
if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return;
|
||||
if (status) {
|
||||
setAttributeIfChanged(candidate, 'data-mnote-attachment-status-error', status);
|
||||
} else {
|
||||
candidate.removeAttribute('data-mnote-attachment-status-error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshEditorLocalAttachmentExistence() {
|
||||
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
|
||||
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
|
||||
@@ -106,6 +181,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
}
|
||||
window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
|
||||
|
||||
function scheduleEditorLocalAttachmentExistenceRefresh() {
|
||||
[120, 500, 1200].forEach(function(delayMs) {
|
||||
window.setTimeout(refreshEditorLocalAttachmentExistence, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
function fileNameFromPath(path) {
|
||||
var value = String(path || '').trim();
|
||||
return value.indexOf('/') >= 0 ? value.split('/').pop() : value;
|
||||
@@ -225,20 +306,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
|| isOfficeFileName(fileName)
|
||||
|| Boolean(localFilePath);
|
||||
if (!shouldEnhance) return;
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
setAttributeIfChanged(link, 'data-mnote-attachment-link', 'true');
|
||||
var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : '');
|
||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||
if (assetId) setAttributeIfChanged(link, 'data-asset-id', assetId);
|
||||
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
|
||||
if (name) link.classList.add(name);
|
||||
});
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
setAttributeIfChanged(link, 'target', '_blank');
|
||||
setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow');
|
||||
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
|
||||
setEditorAttachmentMissingState(link, editorAttachmentMissingByKey.has(localFileOpenKeyFromHref(href)));
|
||||
void refreshLocalAttachmentExistence(link);
|
||||
return;
|
||||
}
|
||||
if (isOnlyOfficeAttachmentHref(href)) {
|
||||
link.setAttribute('href', buildOnlyOfficeOpenPath({
|
||||
setAttributeIfChanged(link, 'href', buildOnlyOfficeOpenPath({
|
||||
fileUrl: params.get('fileUrl') || '',
|
||||
fileName: fileName || '未命名附件',
|
||||
fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
|
||||
@@ -265,6 +347,14 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
enhanceEditorAttachmentLinks();
|
||||
if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer);
|
||||
}, 500);
|
||||
var attachmentExistenceRefreshTimer = window.setInterval(function() {
|
||||
refreshEditorLocalAttachmentExistence();
|
||||
}, 2500);
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (attachmentExistenceRefreshTimer) window.clearInterval(attachmentExistenceRefreshTimer);
|
||||
attachmentExistenceRefreshTimer = 0;
|
||||
});
|
||||
var lastEditorAttachmentMouseOpen = { at: 0, href: '' };
|
||||
|
||||
function ensureAttachmentActions() {
|
||||
var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||
@@ -308,18 +398,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (localFilePath) {
|
||||
if (await openLocalOfficeFileInActiveTab(detail, 'view')) return;
|
||||
var localFileName = detail.fileName || localFilePath.split('/').pop() || localFilePath;
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;
|
||||
// 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
|
||||
void openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
title: detail.fileName || localFilePath.split('/').pop() || localFilePath,
|
||||
kind: fileTreeIconKindForFileName(detail.fileName || localFilePath.split('/').pop() || localFilePath),
|
||||
title: localFileName,
|
||||
kind: fileTreeIconKindForFileName(localFileName),
|
||||
assetId: detail.assetId,
|
||||
documentId: detail.documentId || currentDocumentId() || '',
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
href: localOpenUrl,
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
}).then(function(opened) {
|
||||
if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer');
|
||||
if (!opened) window.open(localOpenUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +450,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||
var localPreviewUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : '';
|
||||
window.open(localOfficeUrl || localPreviewUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var href = detail.href || detail.fileUrl;
|
||||
@@ -464,7 +558,23 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
async function openPdfEditorAttachment(detail) {
|
||||
try {
|
||||
var resolved = await resolveEditorAttachmentUrl(detail);
|
||||
window.open(resolved.url, '_blank', 'noopener,noreferrer');
|
||||
var title = String(detail.fileName || resolved.asset.file_name || 'PDF').trim() || 'PDF';
|
||||
var previewUrl = buildPdfPreviewOpenUrl(resolved.url, title);
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
var didOpenPdfTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:pdf:' + (detail.documentId || '') + ':' + (detail.assetId || previewUrl || resolved.url),
|
||||
assetId: detail.assetId || '',
|
||||
title: title,
|
||||
fileName: title,
|
||||
kind: 'pdf',
|
||||
href: previewUrl,
|
||||
documentId: detail.documentId || '',
|
||||
workspaceId: detail.workspaceId || '',
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
});
|
||||
if (didOpenPdfTab) return;
|
||||
}
|
||||
window.open(previewUrl || resolved.url, '_blank', 'noopener,noreferrer');
|
||||
} catch (error) {
|
||||
window.alert(error && error.message ? error.message : '打开 PDF 失败');
|
||||
}
|
||||
@@ -612,6 +722,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
window.__mnoteEnhanceEditorAttachmentLinks();
|
||||
scheduleEditorAttachmentEnhance();
|
||||
});
|
||||
window.addEventListener('tree:delta', scheduleEditorLocalAttachmentExistenceRefresh);
|
||||
window.addEventListener('tree:resync', scheduleEditorLocalAttachmentExistenceRefresh);
|
||||
[
|
||||
'mnote:leptos-tiptap-spike:ready',
|
||||
'mnote:leptos-tiptap-spike:change',
|
||||
@@ -634,27 +746,35 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
});
|
||||
|
||||
function interceptEditorAttachmentLink(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var editorAttachmentLink = closestAction(event.target, editorAttachmentLinkSelector);
|
||||
if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
|
||||
var href = editorAttachmentLink.href || editorAttachmentLink.getAttribute('href') || '';
|
||||
if (lastEditorAttachmentMouseOpen.href === href && Date.now() - lastEditorAttachmentMouseOpen.at < 900) return;
|
||||
openEditorAttachmentLink(editorAttachmentLink);
|
||||
}
|
||||
|
||||
function suppressEditorAttachmentLinkDefault(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var editorAttachmentLink = closestAction(event.target, editorAttachmentLinkSelector);
|
||||
if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
|
||||
if (Number(event.button || 0) !== 0) return;
|
||||
lastEditorAttachmentMouseOpen = {
|
||||
at: Date.now(),
|
||||
href: editorAttachmentLink.href || editorAttachmentLink.getAttribute('href') || ''
|
||||
};
|
||||
openEditorAttachmentLink(editorAttachmentLink);
|
||||
}
|
||||
|
||||
window.addEventListener('mousedown', suppressEditorAttachmentLinkDefault, true);
|
||||
window.addEventListener('click', interceptEditorAttachmentLink, true);
|
||||
|
||||
document.addEventListener('mouseover', function(event) {
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var link = closestAction(event.target, editorAttachmentLinkSelector);
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
|
||||
enhanceEditorAttachmentLink(link);
|
||||
@@ -662,7 +782,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function(event) {
|
||||
var link = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var link = closestAction(event.target, editorAttachmentLinkSelector);
|
||||
if (!(link instanceof HTMLAnchorElement)) return;
|
||||
var next = event.relatedTarget;
|
||||
var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
|
||||
|
||||
@@ -330,13 +330,18 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
}
|
||||
|
||||
function fileTreeCopyLocalAbsolutePath(detail, trigger) {
|
||||
var rootUri = typeof currentRootUri === 'function' ? currentRootUri() : '';
|
||||
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
|
||||
var rootUri = String(workspacePath && workspacePath.rootUri || '').trim() || (typeof currentRootUri === 'function' ? currentRootUri() : '');
|
||||
if (!rootUri && trigger && typeof trigger.closest === 'function') {
|
||||
var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
|
||||
if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || '';
|
||||
}
|
||||
if (!rootUri && document.body) rootUri = document.body.getAttribute('data-mnote-root-uri') || '';
|
||||
var relativePath = String(detail && detail.localRelativePath || '').trim();
|
||||
var relativePath = String(
|
||||
workspacePath && (workspacePath.relativePath || workspacePath.localRelativePath)
|
||||
|| detail && detail.localRelativePath
|
||||
|| ''
|
||||
).trim();
|
||||
if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
|
||||
if (!relativePath && detail && detail.documentId) {
|
||||
relativePath = String(detail.documentId || '')
|
||||
@@ -609,8 +614,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
button.className = item.danger ? 'mnote-tree-context-menu__item mnote-tree-context-menu__item--danger' : 'mnote-tree-context-menu__item';
|
||||
button.setAttribute('role', 'menuitem');
|
||||
button.setAttribute('data-action', item.action);
|
||||
button.setAttribute('data-command-id', item.commandId || item.action || '');
|
||||
if (item.when) button.setAttribute('data-command-when', item.when);
|
||||
if (item.danger || item.destructive) button.setAttribute('data-destructive', 'true');
|
||||
if (item.requiresApproval) button.setAttribute('data-requires-approval', 'true');
|
||||
button.disabled = item.disabled === true;
|
||||
if (item.title) button.title = item.title;
|
||||
if (item.disabled === true && item.title) button.setAttribute('data-disabled-reason', item.title);
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'material-symbols-outlined mnote-tree-context-menu__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
@@ -636,10 +646,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
|
||||
// ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ──
|
||||
|
||||
function buildSidebarFileTreeContext(kind) {
|
||||
function currentOpenEditorsSnapshotForCommandContext() {
|
||||
try {
|
||||
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') {
|
||||
return window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot();
|
||||
}
|
||||
} catch (_) {}
|
||||
return window.__mnoteOpenEditorsSnapshot || null;
|
||||
}
|
||||
|
||||
function commandContextTargetRow(detail) {
|
||||
if (detail && detail.targetRow instanceof HTMLElement) return detail.targetRow;
|
||||
var rowId = String(detail && detail.rowId || '').trim();
|
||||
if (rowId) {
|
||||
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]');
|
||||
if (row instanceof HTMLElement) return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildSidebarFileTreeContext(kind, detail) {
|
||||
var runtime = window.__mnoteFileTreeContextMenuRuntime;
|
||||
if (runtime && typeof runtime.buildSidebarFileTreeContext === 'function') {
|
||||
try {
|
||||
var targetRow = commandContextTargetRow(detail);
|
||||
return runtime.buildSidebarFileTreeContext(kind, {
|
||||
selection: sidebarFileTreeSelection,
|
||||
currentSourceKind: currentSourceKind,
|
||||
@@ -648,7 +678,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
},
|
||||
queryRowById: function(rowId) {
|
||||
return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]');
|
||||
}
|
||||
},
|
||||
targetRow: targetRow,
|
||||
openEditorsSnapshot: currentOpenEditorsSnapshotForCommandContext(),
|
||||
bufferState: detail && detail.bufferState,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -800,10 +833,17 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var isAttachment = kind === 'attachment';
|
||||
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
|
||||
var isFileTreeDownload = kind === 'filetree' && detail.downloadable;
|
||||
var ctx = buildSidebarFileTreeContext(kind);
|
||||
var ctx = buildSidebarFileTreeContext(kind, detail);
|
||||
menu.setAttribute('data-command-context-source-kind', String(ctx['workspace.sourceKind'] || ''));
|
||||
menu.setAttribute('data-command-context-readonly', String(ctx['workspace.readonly'] === true));
|
||||
menu.setAttribute('data-command-context-selection-count', String(ctx['tree.selectionCount'] || 0));
|
||||
menu.setAttribute('data-command-context-resource-kind', String(ctx['tree.selectionResourceKind'] || ''));
|
||||
menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || ''));
|
||||
menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true));
|
||||
menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true));
|
||||
var items = isAttachment ? [
|
||||
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-link', icon: 'link', label: '复制链接' },
|
||||
{ action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' },
|
||||
@@ -825,9 +865,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
|
||||
{ action: 'new-window', icon: 'open_in_new', label: '在新窗口打开' },
|
||||
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) > 1 ? '下载 ' + Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) + ' 个项目' : '下载' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly && !editor.dirty' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制资源 ID' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' },
|
||||
{ separator: true },
|
||||
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
|
||||
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
|
||||
@@ -849,9 +889,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
|
||||
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2', when: '!workspace.readonly && !editor.dirty' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
|
||||
] : [
|
||||
{ action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
|
||||
{ separator: true },
|
||||
@@ -859,13 +899,18 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
|
||||
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' },
|
||||
{ separator: true },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly' },
|
||||
{ action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly && !editor.dirty' },
|
||||
{ separator: true },
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, when: '!workspace.readonly' }
|
||||
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
|
||||
];
|
||||
items.forEach(function(item) {
|
||||
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
|
||||
item = Object.assign({}, item, { disabled: true, title: item.title || '当前上下文不支持此操作' });
|
||||
var reason = ctx['workspace.readonly'] === true
|
||||
? '当前工作区只读'
|
||||
: ctx['editor.dirty'] === true
|
||||
? '当前目标有未保存修改'
|
||||
: '当前上下文不支持此操作';
|
||||
item = Object.assign({}, item, { disabled: true, title: item.title || reason });
|
||||
}
|
||||
appendTreeContextMenuButton(menu, item, detail, trigger);
|
||||
});
|
||||
@@ -895,12 +940,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
|
||||
var selectedDownloadRows = selectedSidebarFileTreeRowsForDownload(row);
|
||||
var selectedAssetRows = selectedDownloadRows.filter(isFileTreeDownloadableAssetRow);
|
||||
var workspacePath = readWorkspacePathFromRow(row);
|
||||
openTreeContextMenu('filetree', {
|
||||
documentId: documentId,
|
||||
rowId: row.getAttribute('data-row-id') || '',
|
||||
rowKind: row.getAttribute('data-row-kind') || '',
|
||||
assetId: row.getAttribute('data-asset-id') || '',
|
||||
localRelativePath: fileTreeRowLocalRelativePath(row),
|
||||
workspacePath: workspacePath,
|
||||
targetRow: row,
|
||||
title: rowTitle(row),
|
||||
workspaceId: resolveWorkspaceId(row),
|
||||
downloadable: isFileTreeDownloadableRow(row),
|
||||
@@ -1243,6 +1291,32 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : '';
|
||||
}
|
||||
|
||||
function readWorkspacePathFromRow(row) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('readWorkspacePathFromRow');
|
||||
if (runtimeFn) return runtimeFn(row, fileTreeRuntimeDeps());
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
var relativePath = fileTreeRowLocalRelativePath(row);
|
||||
return {
|
||||
schema: 'mnote.workspace_path.v1',
|
||||
workspaceId: resolveWorkspaceId(row),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
relativePath: relativePath,
|
||||
localRelativePath: relativePath,
|
||||
objectIdentity: null,
|
||||
objectIdentityRaw: row.getAttribute('data-object-identity') || '',
|
||||
objectKind: row.getAttribute('data-object-kind') || '',
|
||||
resourceKind: row.getAttribute('data-object-kind') || fileTreeRowKind(row),
|
||||
rowId: row.getAttribute('data-row-id') || '',
|
||||
rowKind: fileTreeRowKind(row),
|
||||
documentId: fileTreeRowDocumentId(row),
|
||||
assetId: fileTreeRowAssetId(row),
|
||||
title: rowTitle(row),
|
||||
href: '',
|
||||
isLocalFolder: currentSourceKind() === 'local_folder'
|
||||
};
|
||||
}
|
||||
|
||||
function fileTreeRowKind(row) {
|
||||
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowKind');
|
||||
if (runtimeFn) return runtimeFn(row);
|
||||
@@ -1281,6 +1355,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
rowKind: fileTreeRowKind(row),
|
||||
assetId: assetId,
|
||||
localRelativePath: relativePath,
|
||||
workspacePath: readWorkspacePathFromRow(row),
|
||||
title: title,
|
||||
fileName: title,
|
||||
workspaceId: resolveWorkspaceId(row)
|
||||
@@ -1728,6 +1803,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|
||||
decodeLocalEncodedPath,
|
||||
fileTreeRowLocalRelativePath,
|
||||
fileTreeRowLocalUploadTargetRelativePath,
|
||||
readWorkspacePathFromRow,
|
||||
fileTreeRowKind,
|
||||
isFileTreeDownloadableAssetRow,
|
||||
isFileTreeDownloadableRow,
|
||||
|
||||
@@ -3,16 +3,19 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
copyWorkspaceSourceParams,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
fileTreeIconKindForFileName,
|
||||
getNavigationInFlight,
|
||||
isCodeAttachmentFileName,
|
||||
isNonOfficeAttachmentName,
|
||||
isPdfAttachmentFileName,
|
||||
openCodeEditorAttachment,
|
||||
resolveWorkspaceId,
|
||||
setNavigationInFlight,
|
||||
shouldOpenLocalResourceInNewWindow,
|
||||
uploadedFileSize,
|
||||
} = dependencies;
|
||||
const currentSourceKindFn = typeof currentSourceKind === 'function' ? currentSourceKind : function() { return ''; };
|
||||
|
||||
function inferOnlyOfficeFileType(fileName, mimeType) {
|
||||
var name = String(fileName || '').trim().toLowerCase();
|
||||
@@ -29,6 +32,9 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
}
|
||||
|
||||
function buildOnlyOfficeOpenUrl(input) {
|
||||
if (shouldUseOfficePreview(input.fileType, input.mode)) {
|
||||
return buildOfficePreviewOpenUrl(input);
|
||||
}
|
||||
var target = new URL('/onlyoffice', window.location.origin);
|
||||
var fileUrl = String(input.fileUrl || '').trim();
|
||||
if (fileUrl) {
|
||||
@@ -46,11 +52,49 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function shouldUseOfficePreview(fileType, mode) {
|
||||
var normalizedMode = String(mode || 'view').trim().toLowerCase();
|
||||
if (normalizedMode === 'edit') return false;
|
||||
var ext = String(fileType || '').trim().toLowerCase();
|
||||
return ['docx', 'xlsx', 'xls', 'csv', 'pptx'].indexOf(ext) >= 0;
|
||||
}
|
||||
|
||||
function buildOfficePreviewOpenUrl(input) {
|
||||
var _rto_ = window.__mnoteResourceOpenRuntime;
|
||||
if (_rto_ && typeof _rto_.buildOfficePreviewOpenUrl === 'function') {
|
||||
return _rto_.buildOfficePreviewOpenUrl(input);
|
||||
}
|
||||
var target = new URL('/office-preview', window.location.origin);
|
||||
var workspacePath = input && input.workspacePath && typeof input.workspacePath === 'object' ? input.workspacePath : {};
|
||||
var fileUrl = String(input.fileUrl || '').trim();
|
||||
if (fileUrl) {
|
||||
try {
|
||||
fileUrl = new URL(fileUrl, window.location.origin).toString();
|
||||
} catch (_error) {}
|
||||
}
|
||||
target.searchParams.set('fileUrl', fileUrl);
|
||||
target.searchParams.set('fileName', input.fileName || '未命名资源');
|
||||
target.searchParams.set('fileType', input.fileType || 'docx');
|
||||
if (input.assetId) target.searchParams.set('assetId', input.assetId);
|
||||
if (input.documentId) target.searchParams.set('documentId', input.documentId);
|
||||
if (input.userId) target.searchParams.set('userId', input.userId);
|
||||
var workspaceId = String(input.workspaceId || workspacePath.workspaceId || '').trim();
|
||||
var sourceKind = String(input.sourceKind || workspacePath.sourceKind || currentSourceKindFn() || '').trim();
|
||||
var rootUri = String(input.rootUri || workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
if (workspaceId) target.searchParams.set('workspaceId', workspaceId);
|
||||
if (sourceKind) target.searchParams.set('sourceKind', sourceKind);
|
||||
if (rootUri) target.searchParams.set('rootUri', rootUri);
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function buildOnlyOfficeOpenPath(input) {
|
||||
var _rto_ = window.__mnoteResourceOpenRuntime;
|
||||
if (_rto_ && typeof _rto_.buildOnlyOfficeOpenPath === 'function') {
|
||||
return _rto_.buildOnlyOfficeOpenPath(input);
|
||||
}
|
||||
if (shouldUseOfficePreview(input.fileType, input.mode)) {
|
||||
return buildOfficePreviewOpenUrl(input);
|
||||
}
|
||||
var params = new URLSearchParams();
|
||||
params.set('fileUrl', input.fileUrl || '');
|
||||
params.set('fileName', input.fileName || '未命名资源');
|
||||
@@ -77,12 +121,17 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
assetId: assetId || ('local-file:' + relativePath),
|
||||
documentId: documentId || currentDocumentId() || '',
|
||||
userId: '',
|
||||
mode: mode || 'view'
|
||||
mode: mode || 'view',
|
||||
workspaceId: resolveWorkspaceId(document.body) || '',
|
||||
sourceKind: currentSourceKindFn() || 'local_folder',
|
||||
rootUri: currentRootUri() || ''
|
||||
});
|
||||
}
|
||||
|
||||
async function openLocalOfficeFileInActiveTab(detail, mode) {
|
||||
var localFilePath = localFilePathFromAssetId(detail.assetId);
|
||||
var workspacePath = workspacePathFromDetail(detail);
|
||||
if (!localFilePath && workspacePath) localFilePath = String(workspacePath.relativePath || workspacePath.localRelativePath || '').trim();
|
||||
if (!localFilePath) return false;
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, mode || 'view');
|
||||
@@ -96,6 +145,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
|
||||
href: buildLocalFileOpenUrl(localFilePath, false),
|
||||
officeUrl: localOfficeUrl,
|
||||
workspacePath: workspacePath,
|
||||
paneRole: detail.paneRole || 'primary'
|
||||
});
|
||||
if (!opened) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
@@ -168,6 +218,36 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : value.indexOf('local:asset:') === 0 ? value.slice('local:asset:'.length) : '';
|
||||
}
|
||||
|
||||
function workspacePathFromDetail(detail) {
|
||||
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
|
||||
return workspacePath && workspacePath.schema === 'mnote.workspace_path.v1' ? workspacePath : null;
|
||||
}
|
||||
|
||||
function workspacePathObjectIdentity(workspacePath) {
|
||||
var objectIdentity = workspacePath && workspacePath.objectIdentity && typeof workspacePath.objectIdentity === 'object'
|
||||
? workspacePath.objectIdentity
|
||||
: null;
|
||||
return objectIdentity || {};
|
||||
}
|
||||
|
||||
function resourceObjectIdentityFromWorkspacePath(input) {
|
||||
input = input || {};
|
||||
var workspacePath = input.workspacePath && typeof input.workspacePath === 'object' ? input.workspacePath : null;
|
||||
var structuredIdentity = workspacePathObjectIdentity(workspacePath);
|
||||
var objectKind = String(input.objectKind || workspacePath && (workspacePath.objectKind || workspacePath.resourceKind) || structuredIdentity.objectKind || '').trim();
|
||||
var documentId = String(input.documentId || workspacePath && workspacePath.documentId || structuredIdentity.documentId || currentDocumentId() || '').trim();
|
||||
var assetId = String(input.assetId || workspacePath && workspacePath.assetId || structuredIdentity.assetId || '').trim();
|
||||
var relativePath = String(input.path || workspacePath && (workspacePath.relativePath || workspacePath.localRelativePath) || '').trim();
|
||||
var rootUri = String(input.rootUri || workspacePath && workspacePath.rootUri || currentRootUri() || '').trim();
|
||||
var fallback = String(input.fallback || '').trim();
|
||||
if (objectKind === 'mindmap' && assetId) return 'resource:mindmap:' + documentId + ':' + assetId;
|
||||
if ((objectKind === 'only_office' || objectKind === 'office') && assetId) return 'resource:onlyoffice:' + documentId + ':' + assetId;
|
||||
if (objectKind === 'pdf' && assetId) return 'resource:pdf:' + documentId + ':' + assetId;
|
||||
if (relativePath && rootUri) return 'resource:file:' + rootUri + ':' + relativePath;
|
||||
if (assetId && documentId) return 'resource:' + (objectKind || 'attachment') + ':' + documentId + ':' + assetId;
|
||||
return fallback || assetId || '';
|
||||
}
|
||||
|
||||
function buildLocalFileOpenUrl(relativePath, download) {
|
||||
var _rto_ = window.__mnoteResourceOpenRuntime;
|
||||
if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') {
|
||||
@@ -182,6 +262,19 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function buildPdfPreviewOpenUrl(fileUrl, fileName) {
|
||||
var _rto_ = window.__mnoteResourceOpenRuntime;
|
||||
if (_rto_ && typeof _rto_.buildPdfPreviewOpenUrl === 'function') {
|
||||
return _rto_.buildPdfPreviewOpenUrl(fileUrl, fileName);
|
||||
}
|
||||
var rawUrl = String(fileUrl || '').trim();
|
||||
if (!rawUrl) return '';
|
||||
var url = new URL('/pdf-preview', window.location.origin);
|
||||
url.searchParams.set('fileUrl', rawUrl);
|
||||
if (fileName) url.searchParams.set('fileName', fileName);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function openLocalResourceInActiveTab(input) {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab !== 'function') return false;
|
||||
var relativePath = String(input && input.path || '').trim();
|
||||
@@ -189,7 +282,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
if (!relativePath || !rootUri) return false;
|
||||
var title = String(input && input.title || '').trim() || relativePath.split('/').pop() || relativePath;
|
||||
var kind = String(input && input.kind || fileTreeIconKindForFileName(title) || '').trim();
|
||||
var objectIdentity = 'resource:file:' + rootUri + ':' + relativePath;
|
||||
var workspacePath = input && input.workspacePath && typeof input.workspacePath === 'object' ? input.workspacePath : null;
|
||||
var objectIdentity = resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: workspacePath,
|
||||
objectKind: kind,
|
||||
documentId: input && input.documentId,
|
||||
assetId: input && input.assetId,
|
||||
path: relativePath,
|
||||
rootUri: rootUri
|
||||
});
|
||||
return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: objectIdentity,
|
||||
assetId: String(input && input.assetId || '').trim() || 'local-file:' + relativePath,
|
||||
@@ -202,6 +303,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
officeUrl: String(input && input.officeUrl || '').trim(),
|
||||
documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
|
||||
workspacePath: workspacePath,
|
||||
paneRole: String(input && input.paneRole || 'primary').trim() || 'primary'
|
||||
});
|
||||
}
|
||||
@@ -240,15 +342,26 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
var forceEditMode = openTarget === 'edit-mode';
|
||||
if (openTarget === 'side') {
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') {
|
||||
var sideWorkspacePath = workspacePathFromDetail(detail);
|
||||
var sideLocalFilePath = localFilePathFromAssetId(assetId);
|
||||
if (!sideLocalFilePath && sideWorkspacePath) sideLocalFilePath = String(sideWorkspacePath.relativePath || sideWorkspacePath.localRelativePath || '').trim();
|
||||
var sideRootUri = String(detail.rootUri || currentRootUri() || '').trim();
|
||||
var sideFileName = String(detail.title || detail.fileName || (sideLocalFilePath ? sideLocalFilePath.split('/').pop() : '') || assetId || '').trim();
|
||||
var sideKind = String(detail.iconKind || detail.assetType || fileTreeIconKindForFileName(sideFileName) || 'file').trim();
|
||||
var sideHref = sideLocalFilePath ? buildLocalFileOpenUrl(sideLocalFilePath, false) : '';
|
||||
var sideOfficeUrl = sideLocalFilePath ? buildLocalOnlyOfficeOpenUrl(sideLocalFilePath, sideFileName, String(detail.documentId || currentDocumentId() || '').trim(), assetId, 'view') : '';
|
||||
if (sideOfficeUrl) sideKind = 'office';
|
||||
if (!sideOfficeUrl && isPdfAttachmentFileName(sideFileName)) sideHref = buildPdfPreviewOpenUrl(sideHref, sideFileName);
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({
|
||||
objectIdentity: sideLocalFilePath && sideRootUri ? 'resource:file:' + sideRootUri + ':' + sideLocalFilePath : String(detail.objectIdentity || detail.assetId || ''),
|
||||
objectIdentity: resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: sideWorkspacePath,
|
||||
objectKind: sideKind,
|
||||
documentId: detail.documentId,
|
||||
assetId: assetId,
|
||||
path: sideLocalFilePath,
|
||||
rootUri: sideRootUri,
|
||||
fallback: String(detail.objectIdentity || detail.assetId || '')
|
||||
}),
|
||||
assetId: assetId,
|
||||
title: sideFileName,
|
||||
fileName: sideFileName,
|
||||
@@ -258,12 +371,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
href: sideHref,
|
||||
officeUrl: sideOfficeUrl,
|
||||
documentId: String(detail.documentId || currentDocumentId() || ''),
|
||||
workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || '')
|
||||
workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || ''),
|
||||
workspacePath: sideWorkspacePath
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
var detailWorkspacePath = workspacePathFromDetail(detail);
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
if (!localFilePath && detailWorkspacePath) localFilePath = String(detailWorkspacePath.relativePath || detailWorkspacePath.localRelativePath || '').trim();
|
||||
if (localFilePath) {
|
||||
var localFileName = localFilePath.split('/').pop() || localFilePath;
|
||||
if (isMindmapAssetDetail(detail) || String(detail && detail.assetType || '').trim() === 'mindmap') {
|
||||
@@ -271,14 +387,21 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:mindmap:' + String(detail && detail.documentId || currentDocumentId() || '').trim() + ':' + assetId,
|
||||
objectIdentity: resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: detailWorkspacePath,
|
||||
objectKind: 'mindmap',
|
||||
documentId: detail && detail.documentId,
|
||||
assetId: assetId,
|
||||
path: localFilePath
|
||||
}),
|
||||
assetId: assetId,
|
||||
mindmapId: assetId,
|
||||
title: String(detail && detail.title || localFileName || '思维导图').trim(),
|
||||
fileName: localFileName,
|
||||
kind: 'mindmap',
|
||||
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim()
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
workspacePath: detailWorkspacePath
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -297,13 +420,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
assetId: assetId,
|
||||
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
officeUrl: localOfficeUrl
|
||||
officeUrl: localOfficeUrl,
|
||||
workspacePath: detailWorkspacePath
|
||||
})) return;
|
||||
window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
if (localFileUrl) {
|
||||
var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;
|
||||
var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);
|
||||
if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({
|
||||
path: localFilePath,
|
||||
@@ -312,9 +437,10 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
assetId: assetId,
|
||||
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
href: localFileUrl
|
||||
href: localOpenUrl,
|
||||
workspacePath: detailWorkspacePath
|
||||
})) return;
|
||||
window.open(localFileUrl, '_blank', 'noopener,noreferrer');
|
||||
window.open(localOpenUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -324,14 +450,20 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-resource-tab');
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
|
||||
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:mindmap:' + documentId + ':' + assetId,
|
||||
objectIdentity: resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: detailWorkspacePath,
|
||||
objectKind: 'mindmap',
|
||||
documentId: documentId,
|
||||
assetId: assetId
|
||||
}),
|
||||
assetId: assetId,
|
||||
mindmapId: assetId,
|
||||
title: String(detail.title || '思维导图').trim(),
|
||||
fileName: String(detail.title || '思维导图').trim(),
|
||||
kind: 'mindmap',
|
||||
documentId: documentId,
|
||||
workspaceId: String(detail.workspaceId || '').trim()
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
workspacePath: detailWorkspacePath
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -367,20 +499,50 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
});
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: 'resource:onlyoffice:' + String(asset.document_id || detail.documentId || '').trim() + ':' + assetId,
|
||||
objectIdentity: resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: detailWorkspacePath,
|
||||
objectKind: 'only_office',
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
assetId: assetId
|
||||
}),
|
||||
assetId: assetId,
|
||||
title: fileName,
|
||||
fileName: fileName,
|
||||
kind: 'office',
|
||||
officeUrl: officeUrl,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim()
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
workspacePath: detailWorkspacePath
|
||||
});
|
||||
if (didOpen) return;
|
||||
}
|
||||
window.open(officeUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (isPdfAttachmentFileName(fileName)) {
|
||||
var pdfPreviewUrl = buildPdfPreviewOpenUrl(fileUrl, fileName);
|
||||
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
|
||||
var didOpenPdf = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
|
||||
objectIdentity: resourceObjectIdentityFromWorkspacePath({
|
||||
workspacePath: detailWorkspacePath,
|
||||
objectKind: 'pdf',
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
assetId: assetId
|
||||
}),
|
||||
assetId: assetId,
|
||||
title: fileName,
|
||||
fileName: fileName,
|
||||
kind: 'pdf',
|
||||
href: pdfPreviewUrl,
|
||||
documentId: String(asset.document_id || detail.documentId || '').trim(),
|
||||
workspaceId: String(detail.workspaceId || '').trim(),
|
||||
workspacePath: detailWorkspacePath
|
||||
});
|
||||
if (didOpenPdf) return;
|
||||
}
|
||||
window.open(pdfPreviewUrl || fileUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
|
||||
await openCodeEditorAttachment({
|
||||
href: fileUrl,
|
||||
@@ -407,8 +569,10 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
buildLocalFileOpenUrl,
|
||||
buildLocalOnlyOfficeOpenUrl,
|
||||
buildMindmapOpenPath,
|
||||
buildOfficePreviewOpenUrl,
|
||||
buildOnlyOfficeOpenPath,
|
||||
buildOnlyOfficeOpenUrl,
|
||||
buildPdfPreviewOpenUrl,
|
||||
fetchCurrentOnlyOfficeUserId,
|
||||
inferOnlyOfficeFileType,
|
||||
isMindmapAssetDetail,
|
||||
@@ -418,5 +582,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
||||
openLocalOfficeFileInActiveTab,
|
||||
openLocalResourceInActiveTab,
|
||||
readFileTreeObjectIdentity,
|
||||
resourceObjectIdentityFromWorkspacePath,
|
||||
workspacePathFromDetail,
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,25 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
searchText,
|
||||
} = context;
|
||||
const MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = context.globalShowHeadingNumbersKey || 'mnote.global.showHeadingNumbers';
|
||||
const PAGE_WIDTH_TYPES = ['default', 'markdown', 'word', 'pdf', 'excel', 'ppt', 'mindmap'];
|
||||
const PAGE_WIDTH_LABELS = {
|
||||
default: '默认',
|
||||
markdown: 'Markdown',
|
||||
word: 'Word',
|
||||
pdf: 'PDF',
|
||||
excel: 'Excel',
|
||||
ppt: 'PPT',
|
||||
mindmap: 'Mindmap'
|
||||
};
|
||||
const DEFAULT_PAGE_WIDTH_PREFERENCES = {
|
||||
default: { mode: 'comfortable', resolvedMode: 'comfortable', cssMaxWidth: '980px', source: 'system' },
|
||||
markdown: { mode: 'readable', resolvedMode: 'readable', cssMaxWidth: '760px', source: 'system' },
|
||||
word: { mode: 'wide', resolvedMode: 'wide', cssMaxWidth: '1180px', source: 'system' },
|
||||
pdf: { mode: 'wide', resolvedMode: 'wide', cssMaxWidth: '1180px', source: 'system' },
|
||||
excel: { mode: 'full', resolvedMode: 'full', cssMaxWidth: 'none', source: 'system' },
|
||||
ppt: { mode: 'wide', resolvedMode: 'wide', cssMaxWidth: '1180px', source: 'system' },
|
||||
mindmap: { mode: 'full', resolvedMode: 'full', cssMaxWidth: 'none', source: 'system' }
|
||||
};
|
||||
|
||||
function defaultPageOptions() {
|
||||
return {
|
||||
@@ -44,6 +63,93 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
return pageUiState.pageOptions;
|
||||
}
|
||||
|
||||
function currentPageWidthPreferences() {
|
||||
return Object.assign({}, DEFAULT_PAGE_WIDTH_PREFERENCES, pageUiState.pageWidthPreferences || {});
|
||||
}
|
||||
|
||||
function pageWidthModeLabel(mode) {
|
||||
if (mode === 'inherit') return '继承默认';
|
||||
if (mode === 'readable') return '阅读';
|
||||
if (mode === 'comfortable') return '舒适';
|
||||
if (mode === 'wide') return '宽版';
|
||||
if (mode === 'full') return '全宽';
|
||||
return '舒适';
|
||||
}
|
||||
|
||||
function normalizePageWidthPreferences(value) {
|
||||
var input = value && typeof value === 'object' ? value : {};
|
||||
var output = {};
|
||||
PAGE_WIDTH_TYPES.forEach(function(type) {
|
||||
var base = DEFAULT_PAGE_WIDTH_PREFERENCES[type];
|
||||
var current = input[type] && typeof input[type] === 'object' ? input[type] : {};
|
||||
var mode = String(current.mode || base.mode || 'comfortable');
|
||||
var resolvedMode = String(current.resolvedMode || current.resolved_mode || (mode === 'inherit' ? (input.default && input.default.resolvedMode) || 'comfortable' : mode));
|
||||
output[type] = {
|
||||
mode: mode,
|
||||
custom: current.custom || null,
|
||||
resolvedMode: resolvedMode,
|
||||
resolvedCustom: current.resolvedCustom || current.resolved_custom || null,
|
||||
cssMaxWidth: String(current.cssMaxWidth || current.css_max_width || pageWidthCssMaxWidth(resolvedMode)),
|
||||
source: String(current.source || base.source || 'system')
|
||||
};
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function pageWidthCssMaxWidth(mode) {
|
||||
if (mode === 'readable') return '760px';
|
||||
if (mode === 'comfortable') return '980px';
|
||||
if (mode === 'wide') return '1180px';
|
||||
if (mode === 'full') return 'none';
|
||||
return '980px';
|
||||
}
|
||||
|
||||
function markdownPageWidthPreference(options) {
|
||||
var preferences = currentPageWidthPreferences();
|
||||
var markdown = preferences.markdown || DEFAULT_PAGE_WIDTH_PREFERENCES.markdown;
|
||||
if (markdown.source === 'system' && options && options.wideLayout) {
|
||||
return {
|
||||
mode: 'comfortable',
|
||||
resolvedMode: 'comfortable',
|
||||
cssMaxWidth: '980px',
|
||||
source: 'wideLayout'
|
||||
};
|
||||
}
|
||||
return markdown;
|
||||
}
|
||||
|
||||
function activeResourceWidthType() {
|
||||
var raw = document.documentElement.getAttribute('data-mnote-active-resource-width-type') || '';
|
||||
var type = String(raw).trim();
|
||||
if (type === 'sheet') return 'excel';
|
||||
if (PAGE_WIDTH_TYPES.indexOf(type) >= 0 && type !== 'default') return type;
|
||||
return '';
|
||||
}
|
||||
|
||||
function activeResourceWidthPreference(options) {
|
||||
var type = activeResourceWidthType();
|
||||
if (!type || type === 'markdown') return markdownPageWidthPreference(options);
|
||||
var preferences = currentPageWidthPreferences();
|
||||
return preferences[type] || DEFAULT_PAGE_WIDTH_PREFERENCES[type] || DEFAULT_PAGE_WIDTH_PREFERENCES.default;
|
||||
}
|
||||
|
||||
function notifyPageWidthPreferenceChanged(type) {
|
||||
var detail = {
|
||||
type: String(type || ''),
|
||||
preferences: currentPageWidthPreferences()
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('mnote:page-width-preference-changed', { detail: detail }));
|
||||
document.querySelectorAll('iframe.mnote-resource-tab-frame').forEach(function(frame) {
|
||||
if (!(frame instanceof HTMLIFrameElement) || !frame.contentWindow) return;
|
||||
try {
|
||||
frame.contentWindow.postMessage({
|
||||
type: 'mnote:page-width-preference-changed',
|
||||
contentType: detail.type
|
||||
}, window.location.origin);
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
function readGlobalShowHeadingNumbers() {
|
||||
return Boolean(currentPageOptions().showHeadingNumbers);
|
||||
}
|
||||
@@ -157,13 +263,19 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
|
||||
if (shell instanceof HTMLElement) {
|
||||
var widthPreference = activeResourceWidthPreference(options);
|
||||
var cssMaxWidth = String(widthPreference.cssMaxWidth || pageWidthCssMaxWidth(widthPreference.resolvedMode));
|
||||
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
shell.setAttribute('data-page-width-content-type', activeResourceWidthType() || 'markdown');
|
||||
shell.setAttribute('data-page-width-mode', String(widthPreference.mode || 'readable'));
|
||||
shell.setAttribute('data-page-width-resolved-mode', String(widthPreference.resolvedMode || widthPreference.mode || 'readable'));
|
||||
shell.setAttribute('data-page-width-source', String(widthPreference.source || 'system'));
|
||||
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||
shell.style.width = '100%';
|
||||
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
|
||||
shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
|
||||
}
|
||||
if (editorRoot instanceof HTMLElement) {
|
||||
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
@@ -177,6 +289,10 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
editorSurface.setAttribute('data-show-heading-numbers', String(showHeadingNumbers));
|
||||
}
|
||||
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||
var rootWidthPreference = activeResourceWidthPreference(options);
|
||||
document.documentElement.setAttribute('data-page-width-content-type', activeResourceWidthType() || 'markdown');
|
||||
document.documentElement.setAttribute('data-page-width-mode', String(rootWidthPreference.mode || 'readable'));
|
||||
document.documentElement.setAttribute('data-page-width-resolved-mode', String(rootWidthPreference.resolvedMode || rootWidthPreference.mode || 'readable'));
|
||||
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
||||
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
|
||||
@@ -235,11 +351,58 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function createPageWidthSelectRow(type) {
|
||||
var options = type === 'default'
|
||||
? [
|
||||
['readable', '阅读'],
|
||||
['comfortable', '舒适'],
|
||||
['wide', '宽版'],
|
||||
['full', '全宽']
|
||||
]
|
||||
: [
|
||||
['inherit', '继承默认'],
|
||||
['readable', '阅读'],
|
||||
['comfortable', '舒适'],
|
||||
['wide', '宽版'],
|
||||
['full', '全宽']
|
||||
];
|
||||
return '' +
|
||||
'<label class="wolai-page-setting-row" data-page-width-row="' + type + '">' +
|
||||
'<span class="wolai-page-setting-copy">' +
|
||||
'<span class="wolai-page-setting-label">' + escapeHtml(PAGE_WIDTH_LABELS[type] || type) + '</span>' +
|
||||
'<span class="wolai-page-setting-hint" data-page-width-hint="' + type + '"></span>' +
|
||||
'</span>' +
|
||||
'<select class="wolai-page-setting-select" data-page-width-select="' + type + '">' +
|
||||
options.map(function(option) {
|
||||
return '<option value="' + option[0] + '">' + option[1] + '</option>';
|
||||
}).join('') +
|
||||
'</select>' +
|
||||
'</label>';
|
||||
}
|
||||
|
||||
function createPageWidthRows() {
|
||||
return '' +
|
||||
'<div class="wolai-page-settings-global-note">页面宽度</div>' +
|
||||
PAGE_WIDTH_TYPES.map(createPageWidthSelectRow).join('');
|
||||
}
|
||||
|
||||
function renderGlobalOptions(popover) {
|
||||
var globalHeadingNumbers = readGlobalShowHeadingNumbers();
|
||||
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
|
||||
input.checked = globalHeadingNumbers;
|
||||
});
|
||||
var preferences = currentPageWidthPreferences();
|
||||
popover.querySelectorAll('[data-page-width-select]').forEach(function(select) {
|
||||
var type = select.getAttribute('data-page-width-select') || '';
|
||||
var preference = preferences[type] || DEFAULT_PAGE_WIDTH_PREFERENCES[type] || DEFAULT_PAGE_WIDTH_PREFERENCES.default;
|
||||
select.value = String(preference.mode || 'comfortable');
|
||||
});
|
||||
popover.querySelectorAll('[data-page-width-hint]').forEach(function(node) {
|
||||
var type = node.getAttribute('data-page-width-hint') || '';
|
||||
var preference = preferences[type] || DEFAULT_PAGE_WIDTH_PREFERENCES[type] || DEFAULT_PAGE_WIDTH_PREFERENCES.default;
|
||||
var resolvedMode = String(preference.resolvedMode || preference.mode || 'comfortable');
|
||||
node.textContent = pageWidthModeLabel(resolvedMode) + ' · ' + String(preference.cssMaxWidth || pageWidthCssMaxWidth(resolvedMode));
|
||||
});
|
||||
}
|
||||
|
||||
function createPageFontRow() {
|
||||
@@ -410,6 +573,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
|
||||
createGlobalHeadingNumbersRow() +
|
||||
createPageWidthRows() +
|
||||
'</div>' +
|
||||
'<div class="wolai-page-settings-actions">' +
|
||||
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">页面历史...</button>' +
|
||||
@@ -657,6 +821,71 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPageWidthPreferences() {
|
||||
if (!currentDocumentId()) return;
|
||||
var params = new URLSearchParams();
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('workspaceId', resolveWorkspaceId(document.body));
|
||||
var sourcePayload = currentWorkspaceSourcePayload();
|
||||
Object.keys(sourcePayload || {}).forEach(function(key) {
|
||||
if (sourcePayload[key] != null && sourcePayload[key] !== '') params.set(key, sourcePayload[key]);
|
||||
});
|
||||
try {
|
||||
var response = await fetch('/api/ui/preferences/effective?' + params.toString(), {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) return;
|
||||
pageUiState.pageWidthPreferences = normalizePageWidthPreferences(payload.result && payload.result.pageWidthPreferences);
|
||||
applyPageOptionsToShell();
|
||||
if (isPageSettingsOpen()) renderPageSettingsPopover();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function persistPageWidthPreference(type, mode) {
|
||||
if (PAGE_WIDTH_TYPES.indexOf(type) < 0) return;
|
||||
var previous = pageUiState.pageWidthPreferences;
|
||||
var next = normalizePageWidthPreferences(previous);
|
||||
next[type] = Object.assign({}, next[type], {
|
||||
mode: mode,
|
||||
resolvedMode: mode === 'inherit' ? next.default.resolvedMode : mode,
|
||||
cssMaxWidth: pageWidthCssMaxWidth(mode === 'inherit' ? next.default.resolvedMode : mode),
|
||||
source: 'optimistic'
|
||||
});
|
||||
pageUiState.pageWidthPreferences = next;
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
notifyPageWidthPreferenceChanged(type);
|
||||
try {
|
||||
var updates = {};
|
||||
updates['pageWidth.' + type] = { mode: mode, custom: null };
|
||||
var response = await fetch('/api/ui/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: currentDocumentId(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
...currentWorkspaceSourcePayload(),
|
||||
updates: updates
|
||||
})
|
||||
});
|
||||
var payload = await response.json().catch(function(){ return null; });
|
||||
if (!response.ok || !payload || payload.ok !== true) {
|
||||
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_width_save_failed_' + response.status);
|
||||
}
|
||||
pageUiState.pageWidthPreferences = normalizePageWidthPreferences(payload.result && payload.result.pageWidthPreferences);
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
notifyPageWidthPreferenceChanged(type);
|
||||
document.documentElement.setAttribute('data-mnote-page-width-saved', 'true');
|
||||
} catch (error) {
|
||||
pageUiState.pageWidthPreferences = previous;
|
||||
applyPageOptionsToShell();
|
||||
renderPageSettingsPopover();
|
||||
document.documentElement.setAttribute('data-mnote-page-width-error', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function isPageSettingsOpen() {
|
||||
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
|
||||
return popover instanceof HTMLElement && !popover.hidden;
|
||||
@@ -684,6 +913,10 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
else openPageSettingsPopover();
|
||||
}
|
||||
|
||||
window.addEventListener('mnote:active-resource-tab-changed', function() {
|
||||
applyPageOptionsToShell();
|
||||
});
|
||||
|
||||
return {
|
||||
applyPageOptionsToShell,
|
||||
closePageHistoryDrawer,
|
||||
@@ -700,11 +933,13 @@ export function createSidebarPageSettingsRuntime(context) {
|
||||
openPageShareDialog,
|
||||
pageOptionIsSupported,
|
||||
persistPageOptionsPatch,
|
||||
persistPageWidthPreference,
|
||||
recordPageHistorySnapshot,
|
||||
renderPageSettingsPopover,
|
||||
setActivePageSettingsTab,
|
||||
togglePageSettingsPopover,
|
||||
updatePageSettingsTriggerState,
|
||||
writeGlobalShowHeadingNumbers,
|
||||
loadPageWidthPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
|
||||
window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
|
||||
documentId: nodeId,
|
||||
workspaceId: workspaceId || '',
|
||||
sourceKind: targetUrl.searchParams.get('sourceKind') || 'convex_workspace',
|
||||
sourceKind: targetUrl.searchParams.get('sourceKind') || 'local_folder',
|
||||
rootUri: targetUrl.searchParams.get('rootUri') || '',
|
||||
url: targetUrl,
|
||||
}).then(function(){
|
||||
|
||||
@@ -418,11 +418,32 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
|
||||
function applyLocalFolderWatchBatch(payload) {
|
||||
var batch = payload && (payload.payload || payload);
|
||||
if (batch && (batch.fallbackResync === true || batch.requiresResync === true)) {
|
||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
return true;
|
||||
}
|
||||
var affectedParents = Array.isArray(batch && batch.affectedParents)
|
||||
? batch.affectedParents
|
||||
: Array.isArray(batch && batch.affected_parents)
|
||||
? batch.affected_parents
|
||||
: [];
|
||||
if (!affectedParents.length) {
|
||||
var changedPaths = Array.isArray(batch && batch.changedPaths)
|
||||
? batch.changedPaths
|
||||
: Array.isArray(batch && batch.changed_paths)
|
||||
? batch.changed_paths
|
||||
: [];
|
||||
affectedParents = changedPaths.map(function(item) {
|
||||
var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim();
|
||||
return { relativePath: parentRelativePathForPath(relativePath), reason: 'derived-from-changed-path' };
|
||||
}).filter(function(parent, index, list) {
|
||||
return index === list.findIndex(function(candidate) { return candidate.relativePath === parent.relativePath; });
|
||||
});
|
||||
if (affectedParents.length) {
|
||||
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-derived-parents', String(affectedParents.length));
|
||||
}
|
||||
}
|
||||
if (!affectedParents.length) {
|
||||
setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents');
|
||||
return false;
|
||||
@@ -663,7 +684,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return {
|
||||
userId: currentActorStorageId(),
|
||||
workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'),
|
||||
sourceKind: String(currentSourceKind() || 'convex_workspace').trim() || 'convex_workspace',
|
||||
sourceKind: String(currentSourceKind() || 'local_folder').trim() || 'local_folder',
|
||||
treeKind: normalizedTreeKind,
|
||||
rootUri: rootUri,
|
||||
scope: scope
|
||||
@@ -685,7 +706,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
|
||||
return SIDEBAR_TREE_VIEW_STATE_KEY
|
||||
.replace('{userId}', encodeURIComponent(scope.userId || 'anonymous'))
|
||||
.replace('{workspaceId}', encodeURIComponent(scope.workspaceId || 'default'))
|
||||
.replace('{sourceKind}', encodeURIComponent(scope.sourceKind || 'convex_workspace'))
|
||||
.replace('{sourceKind}', encodeURIComponent(scope.sourceKind || 'local_folder'))
|
||||
.replace('{treeKind}', encodeURIComponent(scope.treeKind || 'filetree'))
|
||||
.replace('{rootUriHash}', stableTreeViewStateHash(scope.rootUri || ''))
|
||||
.replace('{scopeHash}', stableTreeViewStateHash(scope.scope || 'root'));
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var activeTreeContextMenu = null;
|
||||
var pageUiState = {
|
||||
pageOptions: null,
|
||||
pageWidthPreferences: null,
|
||||
historySnapshots: [],
|
||||
pageSettingsOpen: false,
|
||||
pageAiOpen: false,
|
||||
@@ -244,12 +245,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
|
||||
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
|
||||
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
|
||||
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
|
||||
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
|
||||
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
|
||||
const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args);
|
||||
const togglePageSettingsPopover = (...args) => sidebarPageSettings.togglePageSettingsPopover(...args);
|
||||
const updatePageSettingsTriggerState = (...args) => sidebarPageSettings.updatePageSettingsTriggerState(...args);
|
||||
const writeGlobalShowHeadingNumbers = (...args) => sidebarPageSettings.writeGlobalShowHeadingNumbers(...args);
|
||||
const loadPageWidthPreferences = (...args) => sidebarPageSettings.loadPageWidthPreferences(...args);
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
@@ -413,6 +416,57 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return root instanceof HTMLElement ? String(root.getAttribute('data-mnote-filetree-scope') || '').trim() : '';
|
||||
}
|
||||
|
||||
function recordNavigationRecent(payload) {
|
||||
var body = Object.assign({
|
||||
sourceKind: currentSourceKind() || 'local_folder',
|
||||
rootUri: currentRootUri() || '',
|
||||
}, payload || {});
|
||||
if (!body.kind || !body.rootUri || !body.title) return Promise.resolve(false);
|
||||
return fetch('/api/navigation/recent', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}).then(function(response) {
|
||||
return response.ok;
|
||||
}).catch(function(error) {
|
||||
console.warn('[mnote navigation] record recent failed', error);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function openNavigationPageForFolder(rootUri, relativePath, workspaceId, title, options) {
|
||||
rootUri = String(rootUri || '').trim();
|
||||
relativePath = String(relativePath || '').trim().replace(/^\/+|\/+$/g, '');
|
||||
workspaceId = String(workspaceId || '').trim();
|
||||
title = String(title || relativePath || '本地文件夹').trim();
|
||||
if (!rootUri) return false;
|
||||
void recordNavigationRecent({
|
||||
kind: 'folder',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
relativePath: relativePath,
|
||||
title: title,
|
||||
workspaceId: workspaceId
|
||||
});
|
||||
var targetUrl = new URL('/', window.location.origin);
|
||||
if (workspaceId) targetUrl.searchParams.set('workspaceId', workspaceId);
|
||||
targetUrl.searchParams.set('sourceKind', 'local_folder');
|
||||
targetUrl.searchParams.set('rootUri', rootUri);
|
||||
targetUrl.searchParams.set('treeView', 'filetree');
|
||||
if (relativePath) targetUrl.searchParams.set('fileTreeScope', relativePath);
|
||||
var url = targetUrl.pathname + targetUrl.search;
|
||||
if (options && options.newTab) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentTopbarTitle() {
|
||||
var title = document.querySelector('[data-page-title-current="true"]');
|
||||
return title && title.textContent ? title.textContent.trim() : '无标题';
|
||||
@@ -749,6 +803,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
pageRoot.setAttribute('data-mnote-page-tree-scope', relativePath);
|
||||
}
|
||||
persistStarredFolderScope(workspaceId, rootUri, relativePath);
|
||||
void recordNavigationRecent({
|
||||
kind: 'folder',
|
||||
sourceKind: 'local_folder',
|
||||
rootUri: rootUri,
|
||||
relativePath: relativePath,
|
||||
title: row.textContent ? row.textContent.trim() : relativePath,
|
||||
workspaceId: workspaceId
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath);
|
||||
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
|
||||
sidebarUrl.searchParams.set('workspaceId', workspaceId);
|
||||
@@ -805,10 +867,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
copyWorkspaceSourceParams,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentSourceKind,
|
||||
fileTreeIconKindForFileName: (...args) => fileTreeIconKindForFileName(...args),
|
||||
getNavigationInFlight: () => mnoteNavigationInFlight,
|
||||
isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
|
||||
isNonOfficeAttachmentName: (...args) => isNonOfficeAttachmentName(...args),
|
||||
isPdfAttachmentFileName: (...args) => isPdfAttachmentFileName(...args),
|
||||
openCodeEditorAttachment: (...args) => openCodeEditorAttachment(...args),
|
||||
resolveWorkspaceId,
|
||||
setNavigationInFlight: (value) => { mnoteNavigationInFlight = String(value || ''); },
|
||||
@@ -818,6 +882,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const inferOnlyOfficeFileType = (...args) => sidebarFileTreeOpen.inferOnlyOfficeFileType(...args);
|
||||
const buildOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenUrl(...args);
|
||||
const buildOnlyOfficeOpenPath = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenPath(...args);
|
||||
const buildPdfPreviewOpenUrl = (...args) => sidebarFileTreeOpen.buildPdfPreviewOpenUrl(...args);
|
||||
const buildLocalOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildLocalOnlyOfficeOpenUrl(...args);
|
||||
const openLocalOfficeFileInActiveTab = (...args) => sidebarFileTreeOpen.openLocalOfficeFileInActiveTab(...args);
|
||||
const buildMindmapOpenPath = (...args) => sidebarFileTreeOpen.buildMindmapOpenPath(...args);
|
||||
@@ -925,6 +990,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (ext === 'md' || ext === 'markdown') return 'markdown';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image';
|
||||
if (['html', 'htm', 'css', 'scss', 'less', 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'vue', 'svelte', 'astro'].indexOf(ext) >= 0) return 'web';
|
||||
if (['json', 'jsonc', 'json5', 'toml', 'yaml', 'yml', 'ini', 'env', 'xml', 'lock', 'hcl', 'tf', 'tfvars', 'nix', 'properties', 'conf', 'cfg', 'config', 'service', 'desktop', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'prettierrc', 'eslintrc'].indexOf(ext) >= 0 || ['.dockerignore', '.editorconfig', '.env', '.eslintrc', '.gitattributes', '.gitignore', '.npmrc', '.prettierrc', 'dockerfile', 'containerfile', 'makefile', 'cmakelists.txt', 'gemfile', 'rakefile', 'procfile'].indexOf(name) >= 0) return 'config';
|
||||
if (['rs', 'py', 'go', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'rb', 'sh', 'bash', 'zsh', 'sql', 'lua', 'dart', 'kt', 'kts', 'swift', 'scala', 'gradle', 'groovy', 'clj', 'ex', 'exs', 'erl', 'hrl', 'fs', 'fsx', 'r', 'jl', 'm', 'mm', 'pl', 'pm', 'ps1', 'bat', 'cmd', 'psm1', 'psd1', 'proto', 'graphql', 'gql', 'prisma', 'cmake', 'bazel', 'bzl'].indexOf(ext) >= 0) return 'code';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1720,7 +1788,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false');
|
||||
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed');
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
window.setTimeout(function annotateUploadedLink(attempt) {
|
||||
if (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
|
||||
enhanceEditorAttachmentLinks();
|
||||
var selector = assetId
|
||||
@@ -1729,12 +1797,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
var link = targetRoot instanceof HTMLElement
|
||||
? targetRoot.querySelector(selector)
|
||||
: document.querySelector(selector);
|
||||
if (!(link instanceof HTMLElement) && assetId) {
|
||||
var scope = targetRoot instanceof HTMLElement ? targetRoot : document;
|
||||
link = Array.from(scope.querySelectorAll('.editor-surface .ProseMirror a')).find(function(candidate) {
|
||||
var href = String(candidate.getAttribute('href') || '');
|
||||
try { href = decodeURIComponent(href); } catch (_) {}
|
||||
return href.indexOf(assetId) >= 0;
|
||||
}) || null;
|
||||
}
|
||||
if (link instanceof HTMLElement) {
|
||||
link.setAttribute('data-mnote-attachment-link', 'true');
|
||||
if (assetId) link.setAttribute('data-asset-id', assetId);
|
||||
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel);
|
||||
} else if (Number(attempt) < 20) {
|
||||
window.setTimeout(function() { annotateUploadedLink(Number(attempt) + 1); }, 50);
|
||||
}
|
||||
}, 0);
|
||||
}, 0, 0);
|
||||
return inserted;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1801,7 +1879,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (options && options.insertIntoEditor) {
|
||||
await insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options));
|
||||
}
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
if (!(options && options.insertIntoEditor)) void refreshLocalFolderSidebarSnapshot();
|
||||
window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', {
|
||||
detail: { docId: documentId, asset: localPayload.asset, assetIds: [localPayload.asset.id] }
|
||||
}));
|
||||
@@ -1844,6 +1922,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
window.addEventListener('wolai:assets-changed', function(event) {
|
||||
applyAssetsChangedToFileTree(event.detail || {});
|
||||
});
|
||||
window.addEventListener('wolai:local-assets-changed', function(event) {
|
||||
applyAssetsChangedToFileTree(event.detail || {});
|
||||
});
|
||||
installMindmapAssetFetchObserver();
|
||||
|
||||
async function uploadFilesWithResolvedTarget(files, detail, options) {
|
||||
@@ -2022,6 +2103,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const decodeLocalEncodedPath = (...args) => sidebarFileTreeCommand.decodeLocalEncodedPath(...args);
|
||||
const fileTreeRowLocalRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalRelativePath(...args);
|
||||
const fileTreeRowLocalUploadTargetRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalUploadTargetRelativePath(...args);
|
||||
const readWorkspacePathFromRow = (...args) => sidebarFileTreeCommand.readWorkspacePathFromRow(...args);
|
||||
const fileTreeRowKind = (...args) => sidebarFileTreeCommand.fileTreeRowKind(...args);
|
||||
const isFileTreeDownloadableAssetRow = (...args) => sidebarFileTreeCommand.isFileTreeDownloadableAssetRow(...args);
|
||||
const isFileTreeDownloadableRow = (...args) => sidebarFileTreeCommand.isFileTreeDownloadableRow(...args);
|
||||
@@ -2300,6 +2382,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
|
||||
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
|
||||
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
|
||||
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
|
||||
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
|
||||
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
|
||||
|
||||
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
||||
@@ -2308,12 +2392,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
buildLocalOnlyOfficeOpenUrl,
|
||||
buildOnlyOfficeOpenPath,
|
||||
buildOnlyOfficeOpenUrl,
|
||||
buildPdfPreviewOpenUrl,
|
||||
closestAction,
|
||||
currentDocumentId,
|
||||
currentRootUri,
|
||||
currentWorkspaceSourcePayload,
|
||||
fileTreeIconKindForFileName,
|
||||
healLegacyOfficeAttachmentParagraphs,
|
||||
hydrateEditorAttachmentMeta,
|
||||
inferCodeAttachmentLanguage: (...args) => inferCodeAttachmentLanguage(...args),
|
||||
inferOnlyOfficeFileType,
|
||||
isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
|
||||
@@ -2366,7 +2452,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
|
||||
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
e.preventDefault();
|
||||
openEditorAttachmentLink(editorAttachmentLink);
|
||||
@@ -2503,6 +2589,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiAgent = closestAction(e.target, '[data-page-ai-agent-id]');
|
||||
if (pageAiAgent) {
|
||||
e.preventDefault();
|
||||
pageAiSetAgentId(pageAiAgent.getAttribute('data-page-ai-agent-id') || 'reasonix');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]');
|
||||
if (pageAiContextRef) {
|
||||
e.preventDefault();
|
||||
pageAiToggleContextRef(pageAiContextRef.getAttribute('data-page-ai-context-ref') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
|
||||
if (pageAiMemorySave) {
|
||||
e.preventDefault();
|
||||
@@ -2820,14 +2920,24 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
e.preventDefault();
|
||||
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
|
||||
var objectIdentity = readFileTreeObjectIdentity(fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
var workspacePath = readWorkspacePathFromRow(fileRow);
|
||||
dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId || null, assetType: assetType || null, objectIdentity: objectIdentity, workspacePath: workspacePath, workspaceId: resolveWorkspaceId(fileRow) });
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
return;
|
||||
}
|
||||
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) {
|
||||
void recordNavigationRecent({
|
||||
kind: 'page',
|
||||
sourceKind: currentSourceKind() || 'local_folder',
|
||||
rootUri: currentRootUri(),
|
||||
relativePath: String(fileRow.getAttribute('data-local-relative-path') || '').trim(),
|
||||
documentId: documentId,
|
||||
title: fileTreeRowTitleForShortcut(fileRow, documentId),
|
||||
workspaceId: resolveWorkspaceId(fileRow)
|
||||
});
|
||||
navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree', fileTreeScope: currentFileTreeScope() });
|
||||
} else if (assetId) {
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' });
|
||||
dispatchSidebarEvent('tree.asset.open', { rowId: rowId, rowKind: rowKind, documentId: ownerDocumentId || null, assetId: assetId, assetType: assetType || null, objectIdentity: objectIdentity, workspacePath: workspacePath, workspaceId: resolveWorkspaceId(fileRow), openTarget: e.shiftKey || e.ctrlKey || e.metaKey ? 'new-window' : 'active-tab' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2859,7 +2969,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
});
|
||||
|
||||
document.addEventListener('contextmenu', function(event) {
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]');
|
||||
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]');
|
||||
if (editorAttachmentLink instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2904,6 +3014,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
// inline fallback
|
||||
if (event.key === 'F2') {
|
||||
event.preventDefault();
|
||||
var renameCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')) {
|
||||
return;
|
||||
}
|
||||
beginFileTreeInlineRename(fileTreeRowForKey);
|
||||
return;
|
||||
}
|
||||
@@ -2921,14 +3035,18 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
}
|
||||
if (shortcutKey === 'v') {
|
||||
event.preventDefault();
|
||||
var pasteCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(pasteCtx, '!workspace.readonly')) {
|
||||
return;
|
||||
}
|
||||
void pasteSidebarFileTreeClipboard(fileTreeRowForKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
var delCtx = buildSidebarFileTreeContext('filetree');
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) {
|
||||
var delCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
|
||||
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')) {
|
||||
return;
|
||||
}
|
||||
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
|
||||
@@ -3044,12 +3162,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
||||
if (Object.keys(next).length) {
|
||||
void persistPageOptionsPatch(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var pageWidthSelect = closestAction(event.target, '[data-page-width-select]');
|
||||
if (pageWidthSelect instanceof HTMLSelectElement) {
|
||||
var pageWidthType = pageWidthSelect.getAttribute('data-page-width-select') || '';
|
||||
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
|
||||
}
|
||||
});
|
||||
|
||||
function initializePageUiSurfaces() {
|
||||
pageUiState.pageOptions = null;
|
||||
pageUiState.pageWidthPreferences = null;
|
||||
applyPageOptionsToShell();
|
||||
void loadPageWidthPreferences();
|
||||
updatePageSettingsTriggerState();
|
||||
updatePageAiTriggerState();
|
||||
ensureHistorySnapshotsSeeded();
|
||||
|
||||
@@ -103,7 +103,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
|
||||
) {
|
||||
return 'local_folder';
|
||||
}
|
||||
return 'convex_workspace';
|
||||
return 'local_folder';
|
||||
}
|
||||
|
||||
function currentRootUri() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
|
||||
var fallback = {
|
||||
schema: 'mnote.tree_live_bootstrap.v1',
|
||||
transport: 'convex-command-log-sse',
|
||||
transport: 'tree-live-sse',
|
||||
endpoint: '/api/tree/events',
|
||||
rootIds: [],
|
||||
initialRevision: null
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
function applyTransport(transport) {
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'convex-command-log-sse');
|
||||
document.documentElement.setAttribute('data-mnote-tree-live-transport', transport || 'tree-live-sse');
|
||||
}
|
||||
|
||||
function closeActiveSource() {
|
||||
@@ -169,7 +169,7 @@
|
||||
}
|
||||
|
||||
function startWithSseFallback(bootstrap, workspaceId) {
|
||||
applyTransport('convex-command-log-sse');
|
||||
applyTransport('tree-live-sse');
|
||||
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
|
||||
@@ -208,9 +208,13 @@
|
||||
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
|
||||
|
||||
// Prefer WebSocket transport when available
|
||||
var preferWs = bootstrap.transport === 'convex-command-log-ws' && 'WebSocket' in window;
|
||||
var requestedTransport = bootstrap.transport || 'tree-live-sse';
|
||||
if (requestedTransport === 'convex-command-log-sse') requestedTransport = 'tree-live-sse';
|
||||
if (requestedTransport === 'convex-command-log-ws') requestedTransport = 'tree-live-ws';
|
||||
|
||||
var preferWs = requestedTransport === 'tree-live-ws' && 'WebSocket' in window;
|
||||
if (preferWs) {
|
||||
applyTransport('convex-command-log-ws');
|
||||
applyTransport('tree-live-ws');
|
||||
startWithWebSocket(bootstrap, workspaceId, bootstrap.wsEndpoint || '/api/realtime/ws');
|
||||
return;
|
||||
}
|
||||
@@ -220,7 +224,7 @@
|
||||
applyStatus('unsupported');
|
||||
return;
|
||||
}
|
||||
applyTransport(bootstrap.transport || 'convex-command-log-sse');
|
||||
applyTransport(requestedTransport || 'tree-live-sse');
|
||||
var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
|
||||
url.searchParams.set('workspaceId', workspaceId);
|
||||
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
|
||||
|
||||
@@ -303,7 +303,7 @@ export function createTreeShellFileTreeDndRuntime(context) {
|
||||
`目标: ${targetLabel}`,
|
||||
context.sourceKind === "local_folder"
|
||||
? "目标可写时复制进本地文件夹;命名冲突使用递增命名。"
|
||||
: "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
|
||||
: "legacy cloud 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,11 +63,11 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||
}),
|
||||
createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||
disabled: !canCreateFolder,
|
||||
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
}),
|
||||
createFileTreeMenuItem("upload", "上传/导入", {
|
||||
disabled: true,
|
||||
reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入",
|
||||
reason: localSource ? "外部文件请拖入 Explorer" : "legacy cloud 上传 executor 尚未接入",
|
||||
}),
|
||||
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
|
||||
createFileTreeMenuItem("collapseAll", "全部折叠"),
|
||||
@@ -95,7 +95,7 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||
}));
|
||||
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||
disabled: !canCreateFolder,
|
||||
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||
}));
|
||||
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
||||
disabled: !canPaste,
|
||||
|
||||
@@ -133,13 +133,20 @@ export function getFileTreeRowIconKind(item) {
|
||||
if (iconHint === "table") return "table";
|
||||
if (iconHint === "index") return "index";
|
||||
if (iconHint === "page") return "page";
|
||||
if (iconHint === "pdf") return "pdf";
|
||||
if (iconHint === "office" || iconHint === "onlyoffice") return "office";
|
||||
if (iconHint === "word" || iconHint === "ppt" || iconHint === "sheet") return iconHint;
|
||||
if (iconHint === "image") return "image";
|
||||
if (iconHint === "code" || iconHint === "web" || iconHint === "config") return iconHint;
|
||||
if (item.rowKind === "document") return "page";
|
||||
if (item.rowKind === "folder") return "folder";
|
||||
if (item.rowKind === "markdown") return "file";
|
||||
if (item.rowKind === "markdown") return "markdown";
|
||||
if (item.rowKind === "index") return "index";
|
||||
if (item.rowKind === "asset_folder") return "mindmap";
|
||||
if (item.resourceMeta?.resourceKind === "table") return "table";
|
||||
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
|
||||
if (item.resourceMeta?.resourceKind === "office") return "office";
|
||||
if (item.resourceMeta?.resourceKind === "pdf") return "pdf";
|
||||
return "file";
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ function startTreeShellRuntime() {
|
||||
const sourceKind =
|
||||
typeof state.sourceKind === "string" && state.sourceKind.trim()
|
||||
? state.sourceKind.trim()
|
||||
: "convex_workspace";
|
||||
: "local_folder";
|
||||
const rootUri =
|
||||
typeof state.rootUri === "string" && state.rootUri.trim()
|
||||
? state.rootUri.trim()
|
||||
|
||||
@@ -58,18 +58,8 @@ impl AppConfig {
|
||||
.unwrap_or_else(|_| "/api/hermes".into()),
|
||||
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||
.unwrap_or_else(|_| "/api/compat/next".into()),
|
||||
convex_url: env::var("CONVEX_SELF_HOSTED_URL")
|
||||
.ok()
|
||||
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
|
||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_URL"))
|
||||
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
|
||||
@@ -71,6 +71,12 @@ struct BufferStoreInner {
|
||||
buffers: HashMap<BufferKey, DocumentBuffer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BufferExternalModificationOutcome {
|
||||
pub buffer: DocumentBuffer,
|
||||
pub self_write_echo: bool,
|
||||
}
|
||||
|
||||
impl BufferStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -102,6 +108,8 @@ impl BufferStore {
|
||||
last_loaded_at: Some(now_ms),
|
||||
last_saved_at: Some(now_ms),
|
||||
external_actor: None,
|
||||
last_write_intent_id: None,
|
||||
last_save_operation_id: None,
|
||||
};
|
||||
inner.buffers.insert(key, buf.clone());
|
||||
buf
|
||||
@@ -131,11 +139,27 @@ impl BufferStore {
|
||||
path: &ObjectWorkspacePath,
|
||||
file_version: String,
|
||||
content_hash: String,
|
||||
) -> Option<DocumentBuffer> {
|
||||
self.mark_saved_with_operation(path, file_version, content_hash, None, None)
|
||||
}
|
||||
|
||||
pub fn mark_saved_with_operation(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
file_version: String,
|
||||
content_hash: String,
|
||||
write_intent_id: Option<String>,
|
||||
save_operation_id: Option<String>,
|
||||
) -> Option<DocumentBuffer> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
if let Some(buf) = inner.buffers.get_mut(&key) {
|
||||
buf.mark_saved(file_version, content_hash);
|
||||
buf.mark_saved_with_operation(
|
||||
file_version,
|
||||
content_hash,
|
||||
write_intent_id,
|
||||
save_operation_id,
|
||||
);
|
||||
Some(buf.clone())
|
||||
} else {
|
||||
None
|
||||
@@ -158,6 +182,54 @@ impl BufferStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 带磁盘版本的外部修改标记。
|
||||
///
|
||||
/// watcher 会看到 MNote 自己保存产生的文件系统回声;如果 watcher 观测到的
|
||||
/// `observed_file_version` 等于最近保存/加载的 `file_version`,说明这不是新的外部修改,
|
||||
/// 不应把 Clean 变成 ExternalModified,也不应把当前浏览器正在输入的 Dirty 变成 Stale。
|
||||
pub fn mark_external_modified_if_version_changed(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
observed_file_version: Option<String>,
|
||||
actor: Option<String>,
|
||||
) -> Option<DocumentBuffer> {
|
||||
self.mark_external_modified_if_version_changed_with_outcome(
|
||||
path,
|
||||
observed_file_version,
|
||||
actor,
|
||||
)
|
||||
.map(|outcome| outcome.buffer)
|
||||
}
|
||||
|
||||
pub fn mark_external_modified_if_version_changed_with_outcome(
|
||||
&self,
|
||||
path: &ObjectWorkspacePath,
|
||||
observed_file_version: Option<String>,
|
||||
actor: Option<String>,
|
||||
) -> Option<BufferExternalModificationOutcome> {
|
||||
let key = BufferKey::from_workspace_path(path);
|
||||
let mut inner = self.inner.write().expect("BufferStore lock");
|
||||
let buf = inner.buffers.get_mut(&key)?;
|
||||
if let Some(observed) = observed_file_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if buf.file_version.as_deref() == Some(observed) {
|
||||
return Some(BufferExternalModificationOutcome {
|
||||
buffer: buf.clone(),
|
||||
self_write_echo: true,
|
||||
});
|
||||
}
|
||||
buf.file_version = Some(observed.to_string());
|
||||
}
|
||||
buf.mark_external_modified(actor);
|
||||
Some(BufferExternalModificationOutcome {
|
||||
buffer: buf.clone(),
|
||||
self_write_echo: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 标记 buffer 为 dirty(编辑器内容已修改)。
|
||||
pub fn mark_dirty(
|
||||
&self,
|
||||
@@ -348,6 +420,8 @@ mod tests {
|
||||
last_loaded_at: Some(1_700_000_000_000i64),
|
||||
last_saved_at: Some(1_700_000_000_001i64),
|
||||
external_actor: None,
|
||||
last_write_intent_id: None,
|
||||
last_save_operation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +485,99 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_ignores_watcher_echo_for_last_saved_version() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
store.update(&key, make_fresh_buffer(&path));
|
||||
|
||||
let clean_echo = store
|
||||
.mark_external_modified_if_version_changed(
|
||||
&path,
|
||||
Some("v1".into()),
|
||||
Some("external-editor".into()),
|
||||
)
|
||||
.expect("buffer");
|
||||
assert_eq!(clean_echo.dirty_state, DocBufferDirtyState::Clean);
|
||||
assert_eq!(clean_echo.external_actor, None);
|
||||
|
||||
let dirty = store
|
||||
.mark_dirty(&path, "sha256:dirty".into())
|
||||
.expect("dirty");
|
||||
assert_eq!(dirty.dirty_state, DocBufferDirtyState::Dirty);
|
||||
let dirty_echo = store
|
||||
.mark_external_modified_if_version_changed(
|
||||
&path,
|
||||
Some("v1".into()),
|
||||
Some("external-editor".into()),
|
||||
)
|
||||
.expect("buffer");
|
||||
assert_eq!(dirty_echo.dirty_state, DocBufferDirtyState::Dirty);
|
||||
assert_eq!(dirty_echo.external_actor, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_marks_stale_when_dirty_buffer_sees_new_version() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
let mut buf = make_fresh_buffer(&path);
|
||||
buf.mark_dirty("sha256:dirty".into());
|
||||
store.update(&key, buf);
|
||||
|
||||
let result = store
|
||||
.mark_external_modified_if_version_changed(
|
||||
&path,
|
||||
Some("v2".into()),
|
||||
Some("external-editor".into()),
|
||||
)
|
||||
.expect("buffer");
|
||||
assert_eq!(result.dirty_state, DocBufferDirtyState::Stale);
|
||||
assert_eq!(result.file_version.as_deref(), Some("v2"));
|
||||
assert_eq!(result.external_actor.as_deref(), Some("external-editor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_records_save_operation_and_reports_self_write_echo() {
|
||||
let store = BufferStore::new();
|
||||
let path = make_test_workspace_path("ws_1", "doc.md", "local-md:doc.md");
|
||||
let key = BufferKey::from_workspace_path(&path);
|
||||
let mut buf = make_fresh_buffer(&path);
|
||||
buf.mark_dirty("sha256:dirty".into());
|
||||
store.update(&key, buf);
|
||||
|
||||
let saved = store
|
||||
.mark_saved_with_operation(
|
||||
&path,
|
||||
"v2".into(),
|
||||
"sha256:v2".into(),
|
||||
Some("intent:editor:abc".into()),
|
||||
Some("save:op:abc".into()),
|
||||
)
|
||||
.expect("保存后应更新 buffer");
|
||||
assert_eq!(saved.last_write_intent_id.as_deref(), Some("intent:editor:abc"));
|
||||
assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc"));
|
||||
|
||||
let outcome = store
|
||||
.mark_external_modified_if_version_changed_with_outcome(
|
||||
&path,
|
||||
Some("v2".into()),
|
||||
Some("external-editor".into()),
|
||||
)
|
||||
.expect("watcher 回声应返回 outcome");
|
||||
assert!(outcome.self_write_echo, "同版本 watcher 回声应标记为自写回声");
|
||||
assert_eq!(
|
||||
outcome.buffer.last_write_intent_id.as_deref(),
|
||||
Some("intent:editor:abc")
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.buffer.last_save_operation_id.as_deref(),
|
||||
Some("save:op:abc")
|
||||
);
|
||||
assert_eq!(outcome.buffer.dirty_state, DocBufferDirtyState::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_buffer_mark_saved_after_page_body_write() {
|
||||
let store = BufferStore::new();
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::ensure_local_workspace_access;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -225,7 +225,7 @@ async fn create_artifact_node(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::hermes_tools::doc::{
|
||||
required_arg,
|
||||
};
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use bridge_runtime::{
|
||||
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -1147,7 +1147,7 @@ async fn execute_page_body_save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1221,7 +1221,7 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
|
||||
@@ -1703,6 +1703,8 @@ pub async fn doc_markdown_edit(
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version,
|
||||
write_intent_id: Some("intent:ai:mnote.doc.markdown_edit".into()),
|
||||
save_operation_id: None,
|
||||
base_content_hash: None,
|
||||
content_format: "editorBlocks".into(),
|
||||
content: next_content,
|
||||
@@ -1774,7 +1776,7 @@ pub async fn doc_markdown_edit(
|
||||
validate_only: false,
|
||||
};
|
||||
let execution =
|
||||
crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts(
|
||||
crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -211,6 +211,8 @@ async fn page_command(
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: input.arg_string("expectedFileVersion"),
|
||||
write_intent_id: Some("intent:ai:mnote.page.save".into()),
|
||||
save_operation_id: None,
|
||||
base_content_hash: input.arg_string("baseContentHash"),
|
||||
content_format: "editorBlocks".into(),
|
||||
content,
|
||||
@@ -304,7 +306,7 @@ async fn page_command(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
context,
|
||||
workspace_id.as_deref(),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use crate::routes::{local_workspace_id_from_root_uri, refresh_local_search_index_for_path};
|
||||
use crate::routes::{
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
refresh_local_search_index_for_path,
|
||||
};
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -212,6 +215,12 @@ fn spawn_local_folder_watcher(
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
);
|
||||
let mut observed_file_version: Option<String> = None;
|
||||
let mut buffer_file_version: Option<String> = None;
|
||||
let mut last_write_intent_id: Option<String> = None;
|
||||
let mut last_save_operation_id: Option<String> = None;
|
||||
let mut self_write_echo = false;
|
||||
let event_kind = format!("{:?}", event.kind);
|
||||
let document_id = if is_markdown_path(&path) {
|
||||
let document_id =
|
||||
format!("local-md:{}", encode_local_id_segment(&relative_path));
|
||||
@@ -226,10 +235,40 @@ fn spawn_local_folder_watcher(
|
||||
&relative_path,
|
||||
&document_id,
|
||||
);
|
||||
buffer_store_for_task.mark_external_modified(
|
||||
&ws_path,
|
||||
Some("external-editor".into()),
|
||||
);
|
||||
if event_kind.contains("Remove") {
|
||||
if let Some(buffer) =
|
||||
buffer_store_for_task.mark_local_folder_markdown_deleted(
|
||||
&ws_id,
|
||||
&root_uri_for_task,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
)
|
||||
{
|
||||
buffer_file_version = buffer.file_version.clone();
|
||||
last_write_intent_id =
|
||||
buffer.last_write_intent_id.clone();
|
||||
last_save_operation_id =
|
||||
buffer.last_save_operation_id.clone();
|
||||
}
|
||||
} else {
|
||||
observed_file_version =
|
||||
local_markdown_conflict_detection_key(&document_id, &path)
|
||||
.ok();
|
||||
if let Some(outcome) = buffer_store_for_task
|
||||
.mark_external_modified_if_version_changed_with_outcome(
|
||||
&ws_path,
|
||||
observed_file_version.clone(),
|
||||
Some("external-editor".into()),
|
||||
)
|
||||
{
|
||||
buffer_file_version = outcome.buffer.file_version.clone();
|
||||
last_write_intent_id =
|
||||
outcome.buffer.last_write_intent_id.clone();
|
||||
last_save_operation_id =
|
||||
outcome.buffer.last_save_operation_id.clone();
|
||||
self_write_echo = outcome.self_write_echo;
|
||||
}
|
||||
}
|
||||
}
|
||||
document_id
|
||||
} else {
|
||||
@@ -241,8 +280,13 @@ fn spawn_local_folder_watcher(
|
||||
"rootUri": root_uri_for_task,
|
||||
"relativePath": relative_path,
|
||||
"documentId": document_id,
|
||||
"eventKind": format!("{:?}", event.kind),
|
||||
"eventKind": event_kind,
|
||||
"revision": event_revision(&path),
|
||||
"observedFileVersion": observed_file_version,
|
||||
"bufferFileVersion": buffer_file_version,
|
||||
"lastWriteIntentId": last_write_intent_id,
|
||||
"lastSaveOperationId": last_save_operation_id,
|
||||
"selfWriteEcho": self_write_echo,
|
||||
});
|
||||
let _ = sender_for_task.send(payload);
|
||||
}
|
||||
@@ -375,12 +419,12 @@ fn system_time_ms(time: SystemTime) -> u128 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind,
|
||||
LocalFolderWatcherRegistry,
|
||||
LocalFolderWatcherRegistry, is_local_search_index_path,
|
||||
refresh_local_search_index_for_event, should_emit_event_kind,
|
||||
};
|
||||
use crate::document_buffer_store::BufferStore;
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
use notify::EventKind;
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
|
||||
fn test_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -2,14 +2,14 @@ use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -79,7 +79,7 @@ async fn execute_bridge_query(
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_via_convex(config, context, Some(workspace_id), query).await
|
||||
execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await
|
||||
}
|
||||
|
||||
pub async fn workspace(
|
||||
@@ -152,7 +152,7 @@ pub async fn trace(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2,15 +2,16 @@ use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::{
|
||||
execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution,
|
||||
RetiredCloudCommandExecution, execute_retired_command_plan,
|
||||
execute_retired_command_plan_with_artifacts,
|
||||
};
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
RuntimeTargetWire, execute_runtime_input,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
|
||||
pub fn runtime_context(
|
||||
context: &RequestContext,
|
||||
@@ -63,22 +64,22 @@ pub fn build_runtime_command_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex(
|
||||
pub async fn execute_runtime_command_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?;
|
||||
execute_convex_command_plan(config, context, &plan).await
|
||||
execute_retired_command_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||
pub async fn execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<ConvexCommandExecution, WebError> {
|
||||
) -> Result<RetiredCloudCommandExecution, WebError> {
|
||||
let runtime_context = runtime_context(context, effective_workspace_id);
|
||||
let runtime_input = RuntimeInput::Command {
|
||||
context: runtime_context.clone(),
|
||||
@@ -90,7 +91,7 @@ pub async fn execute_runtime_command_via_convex_with_artifacts(
|
||||
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||
};
|
||||
|
||||
let execution = execute_convex_command_plan_with_artifacts(
|
||||
let execution = execute_retired_command_plan_with_artifacts(
|
||||
state.config(),
|
||||
context,
|
||||
&runtime_context,
|
||||
|
||||
@@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::Json;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -3,24 +3,25 @@ use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
execute_runtime_command_via_legacy_cloud,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -48,6 +49,17 @@ pub struct BufferStateQuery {
|
||||
pub relative_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BufferDirtyRequest {
|
||||
pub document_id: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
fn find_document_buffer_state(
|
||||
buffer_store: &BufferStore,
|
||||
query: &BufferStateQuery,
|
||||
@@ -104,6 +116,20 @@ fn find_document_buffer_state(
|
||||
None
|
||||
}
|
||||
|
||||
fn buffer_state_payload(buf: core_protocol::DocumentBuffer) -> Value {
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSaveRequest {
|
||||
@@ -114,6 +140,8 @@ pub struct DocumentSaveRequest {
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub expected_file_version: Option<String>,
|
||||
pub write_intent_id: Option<String>,
|
||||
pub save_operation_id: Option<String>,
|
||||
pub base_content_hash: Option<String>,
|
||||
pub content_format: Option<String>,
|
||||
pub editor_source: Option<String>,
|
||||
@@ -179,7 +207,9 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
|
||||
)
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::convex::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
@@ -480,6 +510,8 @@ async fn proxy_next_documents_save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -522,7 +554,7 @@ pub async fn load_document_meta_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
fetch_documents_meta_via_convex(
|
||||
fetch_documents_meta_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -553,7 +585,7 @@ pub async fn load_document_content_result(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
execute_runtime_query_via_convex(
|
||||
execute_runtime_query_via_legacy_cloud(
|
||||
state.config(),
|
||||
context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -599,20 +631,7 @@ pub async fn buffer_state(
|
||||
let result = find_document_buffer_state(&state.buffer_store, &query);
|
||||
|
||||
match result {
|
||||
Some(buf) => Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
}),
|
||||
)),
|
||||
Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
|
||||
None => Err(WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
@@ -622,6 +641,51 @@ pub async fn buffer_state(
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记指定打开文档的 BufferStore 状态为 Dirty。
|
||||
///
|
||||
/// 浏览器仍负责具体保存调度;Rust BufferStore 只承担跨 watcher、AI、保存冲突的统一打开态仲裁。
|
||||
pub async fn mark_buffer_dirty(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<BufferDirtyRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(WebError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"document_id_required",
|
||||
"documentId 不能为空",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let query = BufferStateQuery {
|
||||
document_id: Some(document_id.to_string()),
|
||||
workspace_id: body.workspace_id.clone(),
|
||||
source_kind: body.source_kind.clone(),
|
||||
root_uri: body.root_uri.clone(),
|
||||
relative_path: body.relative_path.clone(),
|
||||
};
|
||||
let existing = find_document_buffer_state(&state.buffer_store, &query).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
"未找到该文档的 buffer 状态",
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let content_hash = body
|
||||
.content_hash
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("browser-dirty")
|
||||
.to_string();
|
||||
let marked = state
|
||||
.buffer_store
|
||||
.mark_dirty(&existing.workspace_path, content_hash)
|
||||
.unwrap_or(existing);
|
||||
Ok(ok_response(&context, buffer_state_payload(marked)))
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -690,6 +754,8 @@ pub async fn save(
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
write_intent_id: body.write_intent_id.clone(),
|
||||
save_operation_id: body.save_operation_id.clone(),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
@@ -740,6 +806,8 @@ pub async fn save(
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"writeIntentId": body.write_intent_id,
|
||||
"saveOperationId": body.save_operation_id,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -752,7 +820,7 @@ pub async fn save(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -815,7 +883,7 @@ pub async fn purge(
|
||||
validate_only: false,
|
||||
};
|
||||
let result =
|
||||
execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
|
||||
execute_runtime_command_via_legacy_cloud(state.config(), &context, None, command).await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -865,7 +933,7 @@ pub async fn empty_trash(
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(workspace_id),
|
||||
@@ -968,13 +1036,14 @@ pub async fn title(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page title update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into())],
|
||||
refs: vec![
|
||||
body.command_name
|
||||
.unwrap_or_else(|| "page.head.updateTitle".into()),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1072,13 +1141,14 @@ pub async fn options(
|
||||
}),
|
||||
preflight_data: None,
|
||||
reason: Some("mnote-web page layout update".into()),
|
||||
refs: vec![body
|
||||
.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into())],
|
||||
refs: vec![
|
||||
body.command_name
|
||||
.unwrap_or_else(|| "page.layout.updateOptions".into()),
|
||||
],
|
||||
dry_run: false,
|
||||
validate_only: false,
|
||||
};
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -1111,9 +1181,9 @@ pub async fn options(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -1445,8 +1515,7 @@ mod tests {
|
||||
"upsert_document"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]
|
||||
["title"],
|
||||
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
|
||||
"服务端页面(改名)"
|
||||
);
|
||||
assert_eq!(payload["meta"]["artifactError"], Value::Null);
|
||||
@@ -1727,15 +1796,19 @@ mod tests {
|
||||
payload["details"]["conflict"]["editorBaseVersion"].as_str(),
|
||||
Some(stale_file_version.as_str())
|
||||
);
|
||||
assert!(payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.as_str() == Some("merge")));
|
||||
assert!(
|
||||
payload["details"]["conflict"]["currentDiskVersion"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
assert!(
|
||||
payload["details"]["conflict"]["suggestedActions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|action| action.as_str() == Some("merge"))
|
||||
);
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("# External"));
|
||||
assert!(!markdown.contains("# Editor"));
|
||||
|
||||
@@ -2,14 +2,14 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::documents::{
|
||||
content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery,
|
||||
DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
|
||||
load_local_trash_entries,
|
||||
};
|
||||
@@ -17,24 +18,24 @@ use crate::routes::web_shell::{
|
||||
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
|
||||
render_local_sidebar_tree_html_from_snapshot,
|
||||
};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::transport::convex::execute_retired_mutation_by_name;
|
||||
use crate::workspace_shell::{
|
||||
build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use control_plane::{
|
||||
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
UpsertUserInput,
|
||||
AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||
NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use leptos::prelude::InnerHtmlAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
@@ -73,6 +74,8 @@ pub(crate) struct RootEntryQuery {
|
||||
file_tree_scope: Option<String>,
|
||||
tree_view: Option<String>,
|
||||
restore_focus_row_id: Option<String>,
|
||||
route_guard: Option<String>,
|
||||
missing_page: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn gateway_health(State(state): State<AppState>) -> Response {
|
||||
@@ -260,12 +263,23 @@ pub async fn user_access_policy_entry(
|
||||
pub async fn root_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
uri: Uri,
|
||||
Query(query): Query<RootEntryQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&state, &context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.header(
|
||||
header::LOCATION,
|
||||
format!(
|
||||
"/auth?next={}",
|
||||
query_escape(
|
||||
uri.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/")
|
||||
)
|
||||
),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
@@ -350,21 +364,16 @@ pub async fn root_entry(
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
let mut workspace_projection = build_workspace_shell_projection(
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_or_recent_page_id.as_deref(),
|
||||
"本地文件夹",
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
None,
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
|
||||
if selected_active_page_id.is_none() {
|
||||
apply_active_page(&mut workspace_projection, None);
|
||||
}
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
|
||||
&page_tree_snapshot,
|
||||
selected_active_page_id.as_deref(),
|
||||
@@ -419,21 +428,16 @@ pub async fn root_entry(
|
||||
&workspace_id,
|
||||
&mut workspace_dataset,
|
||||
);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
let mut workspace_projection = build_workspace_shell_projection(
|
||||
&workspace_dataset,
|
||||
&workspace_id,
|
||||
requested_page_id.as_deref(),
|
||||
&default_workspace_name,
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
.first()
|
||||
.map(|item| item.id.as_str()),
|
||||
);
|
||||
let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
|
||||
if selected_active_page_id.is_none() {
|
||||
apply_active_page(&mut workspace_projection, None);
|
||||
}
|
||||
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
|
||||
&snapshot,
|
||||
selected_active_page_id.as_deref(),
|
||||
@@ -521,6 +525,30 @@ pub async fn root_entry(
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
|
||||
let navigation_notice_html = render_navigation_guard_notice(&query);
|
||||
let navigation_html = if active_page_id.trim().is_empty() {
|
||||
if active_source_kind.as_deref() == Some("local_folder") {
|
||||
active_root_uri
|
||||
.as_deref()
|
||||
.map(|root_uri| {
|
||||
render_local_folder_navigation_page_html(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
root_uri,
|
||||
file_tree_scope,
|
||||
&navigation_notice_html,
|
||||
)
|
||||
})
|
||||
.transpose()?
|
||||
} else {
|
||||
Some(render_workspace_navigation_page_html(
|
||||
&navigation_notice_html,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let render_workspace_entry = || {
|
||||
crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage
|
||||
@@ -530,28 +558,25 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
active_page_id={active_page_id.clone()}
|
||||
active_page_title={active_page_title.clone()}
|
||||
navigation_html={navigation_html.clone().unwrap_or_default()}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
})
|
||||
};
|
||||
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
|
||||
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
|
||||
let panes_bootstrap_json = serde_json::to_string(&json!({
|
||||
"schema": "mnote.document_panes_bootstrap.v1",
|
||||
"secondaryRequested": false,
|
||||
"secondaryInvalid": false,
|
||||
"panes": [],
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
format!(
|
||||
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
let panes_bootstrap_json = serde_json::to_string(&json!({
|
||||
"schema": "mnote.document_panes_bootstrap.v1",
|
||||
"secondaryRequested": false,
|
||||
"secondaryInvalid": false,
|
||||
"panes": [],
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let body_extra = format!(
|
||||
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}"#,
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_editor_island_adapter_script(),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_editor_island_adapter_script(),
|
||||
);
|
||||
("MNOTE".to_string(), render_workspace_entry(), body_extra)
|
||||
} else {
|
||||
match build_page_aggregate_snapshot(
|
||||
@@ -617,8 +642,7 @@ pub async fn root_entry(
|
||||
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
|
||||
}
|
||||
};
|
||||
let editor_runtime_preload_links = if body_extra.contains("document-editor-adapter-runtime.js")
|
||||
{
|
||||
let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") {
|
||||
render_editor_runtime_preload_links()
|
||||
} else {
|
||||
""
|
||||
@@ -651,6 +675,333 @@ pub async fn root_entry(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn render_workspace_navigation_page_html(notice_html: &str) -> String {
|
||||
format!(
|
||||
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="workspace" data-navigation-relative-path="">
|
||||
<header class="mnote-navigation-page__header">
|
||||
<h1>主页</h1>
|
||||
</header>
|
||||
{notice_html}
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
|
||||
<h2>最近访问的文件夹</h2>
|
||||
<p>暂无最近访问的文件夹。</p>
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
|
||||
<h2>最近访问的页面</h2>
|
||||
<p>暂无最近访问的页面。</p>
|
||||
</section>
|
||||
</section>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn render_local_folder_navigation_page_html(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: Option<&str>,
|
||||
notice_html: &str,
|
||||
) -> Result<String, WebError> {
|
||||
let relative_path = relative_path.map(str::trim).unwrap_or_default();
|
||||
let snapshot = if relative_path.is_empty() {
|
||||
load_local_folder_file_tree_snapshot(root_uri)?
|
||||
} else {
|
||||
load_local_folder_file_tree_children_snapshot(root_uri, relative_path)?
|
||||
};
|
||||
let items = snapshot
|
||||
.projection
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let title = if relative_path.is_empty() {
|
||||
"本地文件夹".to_string()
|
||||
} else {
|
||||
relative_path
|
||||
.split('/')
|
||||
.next_back()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(relative_path)
|
||||
.to_string()
|
||||
};
|
||||
let mut folders = Vec::new();
|
||||
let mut pages = Vec::new();
|
||||
let mut resources = Vec::new();
|
||||
for item in items {
|
||||
let row_kind = item
|
||||
.get("rowKind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let title = item
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("无标题");
|
||||
let resource_meta = item.get("resourceMeta").and_then(Value::as_object);
|
||||
let item_relative_path = item
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
resource_meta
|
||||
.and_then(|meta| meta.get("workspacePath"))
|
||||
.and_then(|workspace_path| workspace_path.get("relativePath"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let document_id = resource_meta
|
||||
.and_then(|meta| meta.get("documentId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
match row_kind {
|
||||
"folder" | "directory" | "asset-folder" => folders.push(render_navigation_link(
|
||||
title,
|
||||
&navigation_folder_href(root_uri, item_relative_path),
|
||||
"folder",
|
||||
item_relative_path,
|
||||
)),
|
||||
"document" | "markdown" | "index" => {
|
||||
if let Some(document_id) = document_id {
|
||||
pages.push(render_navigation_link(
|
||||
title,
|
||||
&navigation_document_href(root_uri, relative_path, document_id),
|
||||
"page",
|
||||
item_relative_path,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => resources.push(render_navigation_link(
|
||||
title,
|
||||
"#",
|
||||
"resource",
|
||||
item_relative_path,
|
||||
)),
|
||||
}
|
||||
}
|
||||
record_navigation_recent_folder(state, actor_id, root_uri, relative_path, &title, None);
|
||||
let recent_folders = crate::routes::navigation_recent::load_navigation_recent_records(
|
||||
state,
|
||||
context,
|
||||
actor_id,
|
||||
Some("folder"),
|
||||
10,
|
||||
);
|
||||
let recent_pages = crate::routes::navigation_recent::load_navigation_recent_records(
|
||||
state,
|
||||
context,
|
||||
actor_id,
|
||||
Some("page"),
|
||||
20,
|
||||
);
|
||||
|
||||
Ok(format!(
|
||||
r#"<section class="mnote-navigation-page" data-testid="mnote-navigation-page" data-navigation-scope-kind="folder" data-navigation-relative-path="{relative_path}">
|
||||
<header class="mnote-navigation-page__header">
|
||||
<h1>{title}</h1>
|
||||
<p>{path_label}</p>
|
||||
</header>
|
||||
{notice_html}
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-folders">
|
||||
<h2>文件夹</h2>
|
||||
{folders}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-pages">
|
||||
<h2>页面</h2>
|
||||
{pages}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-resources">
|
||||
<h2>资源</h2>
|
||||
{resources}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-folders">
|
||||
<h2>最近访问的文件夹</h2>
|
||||
{recent_folders}
|
||||
</section>
|
||||
<section class="mnote-navigation-page__section" data-testid="mnote-navigation-recent-pages">
|
||||
<h2>最近访问的页面</h2>
|
||||
{recent_pages}
|
||||
</section>
|
||||
</section>"#,
|
||||
relative_path = escape_html(relative_path),
|
||||
title = escape_html(&title),
|
||||
path_label = escape_html(if relative_path.is_empty() {
|
||||
root_uri
|
||||
} else {
|
||||
relative_path
|
||||
}),
|
||||
notice_html = notice_html,
|
||||
folders = if folders.is_empty() {
|
||||
"<p>没有子文件夹。</p>".to_string()
|
||||
} else {
|
||||
folders.join("")
|
||||
},
|
||||
pages = if pages.is_empty() {
|
||||
"<p>没有 Markdown 页面。</p>".to_string()
|
||||
} else {
|
||||
pages.join("")
|
||||
},
|
||||
resources = if resources.is_empty() {
|
||||
"<p>没有资源。</p>".to_string()
|
||||
} else {
|
||||
resources.join("")
|
||||
},
|
||||
recent_folders = render_recent_navigation_items(&recent_folders),
|
||||
recent_pages = render_recent_navigation_items(&recent_pages),
|
||||
))
|
||||
}
|
||||
|
||||
fn render_navigation_guard_notice(query: &RootEntryQuery) -> String {
|
||||
let route_guard = query
|
||||
.route_guard
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let missing_page = query
|
||||
.missing_page
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let Some(message) = route_guard.or(missing_page).map(|_| {
|
||||
if route_guard == Some("local_workspace_access_denied") {
|
||||
"当前账号无权访问原页面,已回到导航页。"
|
||||
} else if route_guard == Some("local_folder_root_required") {
|
||||
"原页面缺少本地文件夹信息,已回到导航页。"
|
||||
} else {
|
||||
"原页面已不存在,已回到当前文件夹导航页。"
|
||||
}
|
||||
}) else {
|
||||
return String::new();
|
||||
};
|
||||
let target = missing_page.or(route_guard).unwrap_or_default();
|
||||
format!(
|
||||
r#"<div class="mnote-navigation-page__notice" data-testid="mnote-navigation-guard-notice" data-mnote-route-guard-target="{}">{}<span>{}</span></div>"#,
|
||||
escape_html(target),
|
||||
escape_html(message),
|
||||
escape_html(target),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_recent_navigation_items(records: &[NavigationRecentRecord]) -> String {
|
||||
if records.is_empty() {
|
||||
return "<p>暂无最近访问。</p>".to_string();
|
||||
}
|
||||
records
|
||||
.iter()
|
||||
.map(|record| {
|
||||
let href = if record.kind == "folder" {
|
||||
navigation_folder_href(
|
||||
&record.root_uri,
|
||||
record.relative_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
} else if let Some(document_id) = record.document_id.as_deref() {
|
||||
navigation_document_href(
|
||||
&record.root_uri,
|
||||
parent_scope_from_relative_path(record.relative_path.as_deref().unwrap_or("")),
|
||||
document_id,
|
||||
)
|
||||
} else {
|
||||
"#".to_string()
|
||||
};
|
||||
render_navigation_link(
|
||||
&record.title,
|
||||
&href,
|
||||
&record.kind,
|
||||
record.relative_path.as_deref().unwrap_or(""),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn render_navigation_link(title: &str, href: &str, kind: &str, relative_path: &str) -> String {
|
||||
format!(
|
||||
r#"<a class="mnote-navigation-page__item" data-navigation-item-kind="{}" data-local-relative-path="{}" href="{}">{}</a>"#,
|
||||
escape_html(kind),
|
||||
escape_html(relative_path),
|
||||
escape_html(href),
|
||||
escape_html(title),
|
||||
)
|
||||
}
|
||||
|
||||
fn record_navigation_recent_folder(
|
||||
state: &AppState,
|
||||
actor_id: &str,
|
||||
root_uri: &str,
|
||||
relative_path: &str,
|
||||
title: &str,
|
||||
workspace_id: Option<&str>,
|
||||
) {
|
||||
if actor_id.trim().is_empty() || actor_id.trim() == "anonymous" || root_uri.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: actor_id.trim().to_string(),
|
||||
workspace_id: workspace_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.trim().to_string(),
|
||||
relative_path: Some(relative_path.trim().trim_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
document_id: None,
|
||||
title: title.trim().to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn parent_scope_from_relative_path(relative_path: &str) -> &str {
|
||||
relative_path
|
||||
.trim()
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent)
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn navigation_folder_href(root_uri: &str, relative_path: &str) -> String {
|
||||
let mut href = format!(
|
||||
"/?sourceKind=local_folder&rootUri={}&treeView=filetree",
|
||||
query_escape(root_uri)
|
||||
);
|
||||
if !relative_path.trim().is_empty() {
|
||||
href.push_str("&fileTreeScope=");
|
||||
href.push_str(&query_escape(relative_path));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn navigation_document_href(root_uri: &str, file_tree_scope: &str, document_id: &str) -> String {
|
||||
let mut href = format!(
|
||||
"/documents/{}?sourceKind=local_folder&rootUri={}&treeView=filetree",
|
||||
query_escape(document_id),
|
||||
query_escape(root_uri)
|
||||
);
|
||||
if !file_tree_scope.trim().is_empty() {
|
||||
href.push_str("&fileTreeScope=");
|
||||
href.push_str(&query_escape(file_tree_scope));
|
||||
}
|
||||
href
|
||||
}
|
||||
|
||||
fn query_escape(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.flat_map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
|
||||
vec![byte as char]
|
||||
}
|
||||
_ => format!("%{byte:02X}").chars().collect::<Vec<_>>(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn trash_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1606,7 +1957,7 @@ async fn resolve_root_workspace_id(
|
||||
return Ok(workspace_id.to_string());
|
||||
}
|
||||
|
||||
let bootstrap = execute_convex_mutation_by_name(
|
||||
let bootstrap = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
@@ -2097,11 +2448,12 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2334,7 +2686,7 @@ mod tests {
|
||||
false,
|
||||
None,
|
||||
Some(
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.luckysheet","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[{"id":"ws_demo","name":"我的空间"}],"documents":[{"id":"page_alive","workspace_id":"ws_demo","title":"保留页面","parent_id":null,"sort_order":0,"is_starred":false}],"trashed_documents":[{"id":"page_trash","workspace_id":"ws_demo","title":"已删页面","parent_id":null,"sort_order":1,"deleted_at":"2026-05-14T00:00:00Z","deleted_by":"user_real"}],"media_assets":[],"trashed_media_assets":[{"id":"asset_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删附件.png","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_assets":[],"trashed_mindmap_assets":[{"id":"mind_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删思维导图.json","deleted_at":"2026-05-14T00:00:00Z"}],"table_assets":[],"trashed_table_assets":[{"id":"table_trash","workspace_id":"ws_demo","document_id":"page_trash","file_name":"已删表格.table","deleted_at":"2026-05-14T00:00:00Z"}],"mindmap_docs":[],"mindmap_asset_children":{}}}"#.into(),
|
||||
),
|
||||
)
|
||||
.oneshot(
|
||||
@@ -2367,7 +2719,7 @@ mod tests {
|
||||
assert!(html.contains(r#"data-resource-kind="table""#));
|
||||
assert!(html.contains("已删附件.png"));
|
||||
assert!(html.contains("已删思维导图.json"));
|
||||
assert!(html.contains("已删表格.luckysheet"));
|
||||
assert!(html.contains("已删表格.table"));
|
||||
assert!(html.contains("new EventSource"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(!html.contains("pollMs"));
|
||||
@@ -2476,6 +2828,30 @@ mod tests {
|
||||
assert_eq!(empty, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_unauthenticated_redirects_to_auth_with_next() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/?sourceKind=local_folder&rootUri=file:///tmp/mnote&treeView=filetree")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(
|
||||
"/auth?next=%2F%3FsourceKind%3Dlocal_folder%26rootUri%3Dfile%3A%2F%2F%2Ftmp%2Fmnote%26treeView%3Dfiletree"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_uses_recent_page_cookie_as_active_page() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
@@ -2580,8 +2956,10 @@ mod tests {
|
||||
assert!(!html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
@@ -2641,7 +3019,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
||||
|
||||
let app_state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
@@ -2827,6 +3205,205 @@ mod tests {
|
||||
assert!(!html.contains(r#"href="/tree"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_without_page_renders_navigation_page_without_editor_bootstrap()
|
||||
{
|
||||
let root = temp_root("mnote-root-local-folder-navigation-page");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Root\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child\n").expect("write child md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("cookie", "mnote_recent_page_id=local-md:README.md")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-navigation-scope-kind="folder""#));
|
||||
assert!(html.contains("README.md"));
|
||||
assert!(html.contains("docs"));
|
||||
assert!(html.contains("最近访问的文件夹"));
|
||||
assert!(html.contains("最近访问的页面"));
|
||||
assert!(!html.contains(r#"data-root-active-page-id="local-md:README.md""#));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
assert!(!html.contains("__MNOTE_EDITOR_BOOTSTRAP__"));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(!html.contains("mnote-leptos-tiptap-spike-island"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_scope_renders_navigation_without_root_sibling_document() {
|
||||
let root = temp_root("mnote-root-local-folder-scope-navigation-page");
|
||||
std::fs::create_dir_all(root.join("design").join("05-editor-mainline"))
|
||||
.expect("create scoped tree");
|
||||
std::fs::write(root.join("Home.md"), "# Home\n").expect("write root page");
|
||||
std::fs::write(root.join("design").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design&missingPage=local-md%3Adesign%7E2FMissing.md"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-navigation-relative-path="design""#));
|
||||
assert!(html.contains("Plan.md"));
|
||||
assert!(html.contains("05-editor-mainline"));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-guard-notice""#));
|
||||
assert!(html.contains("原页面已不存在"));
|
||||
assert!(html.contains("local-md:design~2FMissing.md"));
|
||||
assert!(!html.contains("Home.md"));
|
||||
assert!(!html.contains("__MNOTE_PAGE_AGGREGATE__"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_navigation_renders_user_recent_links() {
|
||||
let root = temp_root("mnote-root-local-folder-navigation-recent");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
std::fs::create_dir_all(root.join("archive")).expect("create archive");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
});
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("archive".to_string()),
|
||||
document_id: None,
|
||||
title: "archive".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("folder recent");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("docs/Plan.md".to_string()),
|
||||
document_id: Some("local-md:docs~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("page recent");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-recent-folders""#));
|
||||
assert!(html.contains(r#"data-local-relative-path="archive""#));
|
||||
assert!(html.contains("fileTreeScope=archive"));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-recent-pages""#));
|
||||
assert!(html.contains(r#"href="/documents/local-md%3Adocs%7E2FPlan.md"#));
|
||||
assert!(html.contains("fileTreeScope=docs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() {
|
||||
let root = temp_root("mnote-root-local-folder-page-tree-snapshot-once");
|
||||
@@ -2859,7 +3436,9 @@ mod tests {
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads(),
|
||||
crate::routes::local_folder_source::local_page_tree_snapshot_test_loads_for_root(
|
||||
&root_uri
|
||||
),
|
||||
1,
|
||||
"root entry should reuse the already loaded local page tree snapshot instead of scanning it twice",
|
||||
);
|
||||
@@ -2907,7 +3486,9 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#));
|
||||
assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#));
|
||||
assert!(
|
||||
html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)
|
||||
);
|
||||
assert!(
|
||||
!html.contains(r#"data-node-id="local-md:Home.md""#),
|
||||
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树"
|
||||
@@ -2953,17 +3534,18 @@ mod tests {
|
||||
assert!(html.contains(r#"data-row-id="local:folder:attachments""#));
|
||||
assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-navigation-page""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-leptos-tiptap-island-editor-root""#));
|
||||
assert!(html.contains(r#"data-pane-role="primary""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains(
|
||||
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
assert!(!html.contains(
|
||||
r#"<link rel="modulepreload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js">"#
|
||||
));
|
||||
assert!(html.contains(
|
||||
assert!(!html.contains(
|
||||
r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
|
||||
));
|
||||
assert!(
|
||||
@@ -2991,7 +3573,7 @@ mod tests {
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("/auth")
|
||||
Some("/auth?next=%2F")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
@@ -3090,12 +3672,14 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote-web")
|
||||
);
|
||||
assert!(response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("text/html"));
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.contains("text/html")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3150,12 +3734,16 @@ mod tests {
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values.iter().any(|value| value.contains("mnote_session=")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=new-user")));
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user")));
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_id=new-user"))
|
||||
);
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_actor_type=user"))
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -3234,10 +3822,12 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "auth_signup_email_required");
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("注册账号时请填写邮箱"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("注册账号时请填写邮箱")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3261,9 +3851,11 @@ mod tests {
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3369,12 +3961,16 @@ mod tests {
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthJWT=")));
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken=")));
|
||||
assert!(
|
||||
!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthJWT="))
|
||||
);
|
||||
assert!(
|
||||
!values
|
||||
.iter()
|
||||
.any(|value| value.contains("__convexAuthRefreshToken="))
|
||||
);
|
||||
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
|
||||
runtime_input_requests_result, RuntimeInput,
|
||||
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
|
||||
execute_runtime_query, runtime_input_requests_result,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
|
||||
@@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -253,10 +253,12 @@ mod tests {
|
||||
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
|
||||
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
|
||||
assert!(payload["eventStreamEndpoint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/api/hermes/events/"));
|
||||
assert!(
|
||||
payload["eventStreamEndpoint"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/api/hermes/events/")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,11 @@ use super::hermes_client;
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
|
||||
use crate::hermes_tools::{ToolCallInput, artifact, block, doc, manifest, page, resource};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
@@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
@@ -1112,36 +1112,56 @@ mod tests {
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
|
||||
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.replace"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.insert_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.delete"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.move_after"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
|
||||
assert!(tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.plan_update")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.replace")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.insert_after")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.delete")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.block.move_after")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.fetch")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.fetch_summary")
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == "mnote.office.propose_changes")
|
||||
);
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
@@ -1189,16 +1209,20 @@ mod tests {
|
||||
}
|
||||
assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object());
|
||||
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
|
||||
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["operations"])));
|
||||
assert!(markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["full_content"])));
|
||||
assert!(
|
||||
markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["operations"]))
|
||||
);
|
||||
assert!(
|
||||
markdown_edit["inputSchema"]["anyOf"]
|
||||
.as_array()
|
||||
.expect("anyOf")
|
||||
.iter()
|
||||
.any(|rule| rule["required"] == json!(["full_content"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1228,14 +1252,18 @@ mod tests {
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
.expect("page save tool");
|
||||
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容"));
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff"));
|
||||
assert!(
|
||||
markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容")
|
||||
);
|
||||
assert!(
|
||||
markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff")
|
||||
);
|
||||
assert_eq!(
|
||||
page_save["annotations"]["requiresWritePermission"],
|
||||
Value::Bool(true)
|
||||
@@ -1426,10 +1454,12 @@ mod tests {
|
||||
payload["result"]["objectIdentity"],
|
||||
"resource:mindmap:local-md:README.md:mind_allowed"
|
||||
);
|
||||
assert!(payload["result"]["markdownSummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("中心主题"));
|
||||
assert!(
|
||||
payload["result"]["markdownSummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("中心主题")
|
||||
);
|
||||
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1765,10 +1795,12 @@ mod tests {
|
||||
assert_eq!(payload["toolName"], "mnote.page.get");
|
||||
assert_eq!(payload["toolCallId"], "call_1");
|
||||
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||
assert!(payload["result"]["bodySummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("章节一"));
|
||||
assert!(
|
||||
payload["result"]["bodySummary"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("章节一")
|
||||
);
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
}
|
||||
|
||||
@@ -1814,10 +1846,12 @@ mod tests {
|
||||
json!("heading_1")
|
||||
);
|
||||
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
|
||||
assert!(payload["result"]["blocks"][0]["revisionRef"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||
assert!(
|
||||
payload["result"]["blocks"][0]["revisionRef"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("pageRev:7:block:heading_1:hash:")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1944,10 +1978,12 @@ mod tests {
|
||||
assert_eq!(payload["result"]["scope"], "selection");
|
||||
assert_eq!(payload["result"]["format"], "page_xml");
|
||||
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
|
||||
assert!(payload["result"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("<block id=\"heading_1\""));
|
||||
assert!(
|
||||
payload["result"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("<block id=\"heading_1\"")
|
||||
);
|
||||
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
|
||||
}
|
||||
|
||||
@@ -2210,10 +2246,12 @@ mod tests {
|
||||
insert_payload["result"]["changedBlocks"][0]["op"],
|
||||
json!("insert_after")
|
||||
);
|
||||
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("ai_block_"));
|
||||
assert!(
|
||||
insert_payload["result"]["changedBlocks"][0]["blockId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("ai_block_")
|
||||
);
|
||||
|
||||
let delete_response = app()
|
||||
.oneshot(
|
||||
@@ -2565,9 +2603,11 @@ mod tests {
|
||||
.as_array()
|
||||
.expect("insertedBlockIds");
|
||||
assert_eq!(inserted_ids.len(), 2);
|
||||
assert!(inserted_ids
|
||||
.iter()
|
||||
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
|
||||
assert!(
|
||||
inserted_ids
|
||||
.iter()
|
||||
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") })
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["changedBlocks"]
|
||||
.as_array()
|
||||
|
||||
@@ -8,16 +8,16 @@ use crate::routes::local_folder_source::{
|
||||
};
|
||||
use crate::routes::query_support::resolve_effective_workspace_id;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelGraphDirection, KernelProjectionKind};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
|
||||
@@ -264,9 +264,9 @@ pub async fn graph(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -287,7 +287,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()),
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -409,17 +409,19 @@ mod tests {
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["result"]["projection"], "file_tree");
|
||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||
assert!(payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.any(|item| item["title"] == "本地页面"
|
||||
&& item["rowKind"] == "folder"
|
||||
&& item["documentId"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:")
|
||||
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面"));
|
||||
assert!(
|
||||
payload["result"]["items"]
|
||||
.as_array()
|
||||
.expect("items")
|
||||
.iter()
|
||||
.any(|item| item["title"] == "本地页面"
|
||||
&& item["rowKind"] == "folder"
|
||||
&& item["documentId"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.starts_with("local-md:")
|
||||
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -513,9 +515,11 @@ mod tests {
|
||||
assert!(titles.contains(&"README.md"));
|
||||
assert!(titles.contains(&"nested"));
|
||||
assert!(!titles.contains(&"deep.md"));
|
||||
assert!(items
|
||||
.iter()
|
||||
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs"))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -676,11 +680,13 @@ mod tests {
|
||||
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
|
||||
"mindmap"
|
||||
);
|
||||
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand"));
|
||||
assert!(
|
||||
item_by_row_id["asset-folder:mind_1"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value == "expand")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, Sse};
|
||||
use futures_util::stream;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::{interval, timeout, MissedTickBehavior};
|
||||
use tokio::time::{MissedTickBehavior, interval, timeout};
|
||||
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
@@ -29,6 +29,7 @@ type BoxedEventStream =
|
||||
pub struct LocalFolderEventsQuery {
|
||||
pub root_uri: String,
|
||||
pub document_id: Option<String>,
|
||||
pub resource_path: Option<String>,
|
||||
pub tree_live: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -68,7 +69,8 @@ async fn build_document_events_stream(
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id);
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
.or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path));
|
||||
let subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
@@ -78,6 +80,7 @@ async fn build_document_events_stream(
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": subscription.root_uri(),
|
||||
"documentId": query.document_id,
|
||||
"resourcePath": query.resource_path,
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold(
|
||||
@@ -117,7 +120,7 @@ async fn build_document_events_stream(
|
||||
/// with full sidebar + file tree projections.
|
||||
///
|
||||
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
|
||||
/// No data is written to Convex command log.
|
||||
/// No data is written to an external/cloud command log.
|
||||
async fn build_tree_live_stream(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
@@ -423,6 +426,18 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
|
||||
decode_local_id_segment(encoded).ok()
|
||||
}
|
||||
|
||||
fn normalize_resource_event_path(resource_path: &str) -> Option<String> {
|
||||
let trimmed = resource_path.trim().trim_start_matches('/');
|
||||
if trimmed.is_empty()
|
||||
|| PathBuf::from(trimmed)
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn document_event_targets_relative_path(payload: &Value, expected: &str) -> bool {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
@@ -457,7 +472,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
@@ -577,6 +592,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_event_path_filters_to_its_own_relative_path() {
|
||||
let expected = normalize_resource_event_path("README.assets/slides.pptx")
|
||||
.expect("resource path should normalize");
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/slides.pptx",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 1,
|
||||
});
|
||||
let other_payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/report.pdf",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 2,
|
||||
});
|
||||
|
||||
assert!(document_event_targets_relative_path(&payload, &expected));
|
||||
assert!(!document_event_targets_relative_path(&other_payload, &expected));
|
||||
assert!(
|
||||
normalize_resource_event_path("../escape.pdf").is_none(),
|
||||
"resource watch path 不能越过 root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tree_snapshot_payload_has_required_fields() {
|
||||
let sidebar_projection = json!({
|
||||
@@ -697,11 +741,13 @@ mod tests {
|
||||
&& parent["reason"].as_str() == Some("child-watch")),
|
||||
"watch batch 应声明 docs affected parent: {payload}"
|
||||
);
|
||||
assert!(payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||
assert!(
|
||||
payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)"))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ use crate::routes::local_markdown_parser::{
|
||||
};
|
||||
use crate::routes::local_search_index;
|
||||
use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use bridge_runtime::project_legacy_content_to_block_document;
|
||||
use control_plane::AppendAuditInput;
|
||||
use control_plane::{
|
||||
@@ -24,7 +24,7 @@ use core_protocol::{
|
||||
};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -41,6 +41,9 @@ static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS: std::sync::atomic::AtomicU64 =
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
#[cfg(test)]
|
||||
static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT: OnceLock<Mutex<BTreeMap<String, u64>>> =
|
||||
OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LocalPageTreeSnapshotCacheEntry {
|
||||
@@ -52,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
|
||||
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn local_page_tree_snapshot_cache(
|
||||
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||
fn local_page_tree_snapshot_cache()
|
||||
-> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
}
|
||||
|
||||
@@ -61,6 +64,11 @@ fn local_page_tree_snapshot_cache(
|
||||
pub(crate) fn reset_local_page_tree_snapshot_test_loads() {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT.get() {
|
||||
if let Ok(mut loads) = loads.lock() {
|
||||
loads.clear();
|
||||
}
|
||||
}
|
||||
if let Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() {
|
||||
if let Ok(mut cache) = cache.lock() {
|
||||
cache.clear();
|
||||
@@ -69,8 +77,13 @@ pub(crate) fn reset_local_page_tree_snapshot_test_loads() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn local_page_tree_snapshot_test_loads() -> u64 {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst)
|
||||
pub(crate) fn local_page_tree_snapshot_test_loads_for_root(root_uri: &str) -> u64 {
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|loads| loads.get(root_uri).copied())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2784,6 +2797,15 @@ fn load_local_folder_page_tree_snapshot_for_scope(
|
||||
) -> Result<ProjectionSnapshot, WebError> {
|
||||
#[cfg(test)]
|
||||
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Ok(mut loads) = LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT
|
||||
.get_or_init(|| Mutex::new(BTreeMap::new()))
|
||||
.lock()
|
||||
{
|
||||
*loads.entry(root_uri.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
@@ -3255,10 +3277,12 @@ pub fn write_local_markdown_page_body(
|
||||
relative_path,
|
||||
&request.document_id,
|
||||
);
|
||||
store.mark_saved(
|
||||
store.mark_saved_with_operation(
|
||||
&ws_path,
|
||||
file_version.to_string(),
|
||||
format!("sha256:{file_version}"),
|
||||
request.write_intent_id.clone(),
|
||||
request.save_operation_id.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3283,6 +3307,22 @@ pub fn write_local_markdown_page_body(
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
map.insert(
|
||||
"writeIntentId".into(),
|
||||
request
|
||||
.write_intent_id
|
||||
.as_deref()
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
map.insert(
|
||||
"saveOperationId".into(),
|
||||
request
|
||||
.save_operation_id
|
||||
.as_deref()
|
||||
.map(Value::from)
|
||||
.unwrap_or(Value::Null),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -3818,7 +3858,7 @@ pub(crate) fn write_local_folder_file_upload(
|
||||
"上传目标必须是本地 root 内的目录",
|
||||
));
|
||||
}
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let sanitized_name = sanitize_uploaded_file_name(&file.name, "附件");
|
||||
let target = next_available_raw_path(&target_dir, &sanitized_name);
|
||||
fs::write(&target, &file.bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -3908,7 +3948,7 @@ pub(crate) fn write_local_markdown_asset(
|
||||
)
|
||||
})?;
|
||||
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let sanitized_name = sanitize_uploaded_file_name(&file.name, "附件");
|
||||
let target = next_available_asset_path(&asset_dir, &sanitized_name);
|
||||
if !target.starts_with(&canonical_root) {
|
||||
return Err(WebError::bad_request_code(
|
||||
@@ -7023,11 +7063,7 @@ fn parent_key_for_relative_path(relative_path: &str) -> String {
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
.map(|value| normalize_file_order_parent_key(&value))
|
||||
.unwrap_or_else(|| ".".to_string())
|
||||
@@ -7517,11 +7553,16 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
}
|
||||
"asset" => match row.icon_hint.as_str() {
|
||||
"mindmap" => KernelObjectKind::Mindmap,
|
||||
"onlyoffice" | "office" => KernelObjectKind::OnlyOffice,
|
||||
"onlyoffice" | "office" | "word" | "ppt" | "sheet" => KernelObjectKind::OnlyOffice,
|
||||
_ => KernelObjectKind::Attachment,
|
||||
},
|
||||
_ => KernelObjectKind::Attachment,
|
||||
};
|
||||
let object_asset_id = if row.row_kind == "asset" && !row.relative_path.trim().is_empty() {
|
||||
Some(format!("local-file:{}", row.relative_path))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let workspace_path = ObjectWorkspacePath {
|
||||
workspace_id: row.workspace_id.clone(),
|
||||
source_kind: WorkspaceSourceKind::LocalFolder,
|
||||
@@ -7531,7 +7572,7 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
|
||||
object_kind,
|
||||
document_id: row.document_id.clone(),
|
||||
block_id: None,
|
||||
asset_id: None,
|
||||
asset_id: object_asset_id,
|
||||
},
|
||||
resource_kind: Some(row.row_kind.clone()),
|
||||
};
|
||||
@@ -7733,21 +7774,22 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
|
||||
"pdf"
|
||||
} else if matches!(
|
||||
extension(&lower).as_deref(),
|
||||
Some(
|
||||
"doc"
|
||||
| "docx"
|
||||
| "odt"
|
||||
| "rtf"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "odp"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ods"
|
||||
| "csv"
|
||||
)
|
||||
Some("doc" | "docx" | "odt" | "rtf")
|
||||
) {
|
||||
"office"
|
||||
"word"
|
||||
} else if matches!(extension(&lower).as_deref(), Some("ppt" | "pptx" | "odp")) {
|
||||
"ppt"
|
||||
} else if matches!(
|
||||
extension(&lower).as_deref(),
|
||||
Some("xls" | "xlsx" | "ods" | "csv")
|
||||
) {
|
||||
"sheet"
|
||||
} else if is_web_file_name(&lower) {
|
||||
"web"
|
||||
} else if is_config_file_name(&lower) {
|
||||
"config"
|
||||
} else if is_code_file_name(&lower) {
|
||||
"code"
|
||||
} else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
|
||||
"book"
|
||||
} else {
|
||||
@@ -7756,6 +7798,135 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_web_file_name(file_name: &str) -> bool {
|
||||
matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"html"
|
||||
| "htm"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "less"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "mjs"
|
||||
| "cjs"
|
||||
| "vue"
|
||||
| "svelte"
|
||||
| "astro"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_config_file_name(file_name: &str) -> bool {
|
||||
let code_file_names = [
|
||||
".dockerignore",
|
||||
".editorconfig",
|
||||
".env",
|
||||
".eslintrc",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
".npmrc",
|
||||
".prettierrc",
|
||||
"dockerfile",
|
||||
"containerfile",
|
||||
"makefile",
|
||||
"cmakelists.txt",
|
||||
"gemfile",
|
||||
"rakefile",
|
||||
"procfile",
|
||||
];
|
||||
code_file_names.contains(&file_name)
|
||||
|| matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"json"
|
||||
| "jsonc"
|
||||
| "json5"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "ini"
|
||||
| "env"
|
||||
| "xml"
|
||||
| "lock"
|
||||
| "hcl"
|
||||
| "tf"
|
||||
| "tfvars"
|
||||
| "nix"
|
||||
| "properties"
|
||||
| "conf"
|
||||
| "cfg"
|
||||
| "config"
|
||||
| "service"
|
||||
| "desktop"
|
||||
| "gitignore"
|
||||
| "gitattributes"
|
||||
| "editorconfig"
|
||||
| "npmrc"
|
||||
| "prettierrc"
|
||||
| "eslintrc"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_code_file_name(file_name: &str) -> bool {
|
||||
matches!(
|
||||
extension(file_name).as_deref(),
|
||||
Some(
|
||||
"rs" | "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "php"
|
||||
| "rb"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "sql"
|
||||
| "lua"
|
||||
| "dart"
|
||||
| "kt"
|
||||
| "kts"
|
||||
| "swift"
|
||||
| "scala"
|
||||
| "gradle"
|
||||
| "groovy"
|
||||
| "clj"
|
||||
| "ex"
|
||||
| "exs"
|
||||
| "erl"
|
||||
| "hrl"
|
||||
| "fs"
|
||||
| "fsx"
|
||||
| "r"
|
||||
| "jl"
|
||||
| "m"
|
||||
| "mm"
|
||||
| "pl"
|
||||
| "pm"
|
||||
| "ps1"
|
||||
| "bat"
|
||||
| "cmd"
|
||||
| "psm1"
|
||||
| "psd1"
|
||||
| "proto"
|
||||
| "graphql"
|
||||
| "gql"
|
||||
| "prisma"
|
||||
| "cmake"
|
||||
| "bazel"
|
||||
| "bzl"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn extension(file_name: &str) -> Option<String> {
|
||||
Path::new(file_name)
|
||||
.extension()
|
||||
@@ -7850,6 +8021,15 @@ fn sanitize_file_name(value: &str, fallback: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_uploaded_file_name(value: &str, fallback: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
let leaf = trimmed
|
||||
.rsplit(['/', '\\'])
|
||||
.find(|part| !part.trim().is_empty())
|
||||
.unwrap_or(trimmed);
|
||||
sanitize_file_name(leaf, fallback)
|
||||
}
|
||||
|
||||
fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf {
|
||||
let first = directory.join(format!("{stem}.{extension}"));
|
||||
if !first.exists() {
|
||||
@@ -8919,7 +9099,7 @@ fn system_time_ms(time: SystemTime) -> u128 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn local_markdown_conflict_detection_key(
|
||||
pub(crate) fn local_markdown_conflict_detection_key(
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> Result<String, WebError> {
|
||||
@@ -9044,6 +9224,10 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
|
||||
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
|
||||
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
|
||||
SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
add_sqlite_local_access_grant_for_context,
|
||||
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
|
||||
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
|
||||
@@ -9062,18 +9246,15 @@ mod tests {
|
||||
resolve_local_markdown_page_aggregate, save_local_markdown_page,
|
||||
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
|
||||
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
|
||||
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
|
||||
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
|
||||
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
|
||||
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
|
||||
write_sync_conflict_report,
|
||||
};
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path as AxumPath, Query, State};
|
||||
use axum::http::{HeaderMap, Method, StatusCode};
|
||||
use axum::Json;
|
||||
use control_plane::{DirectoryGrantInput, UpsertUserInput};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
@@ -9345,28 +9526,25 @@ mod tests {
|
||||
"tableCell"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
|
||||
"左"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"],
|
||||
"A"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]
|
||||
["marks"][0]["type"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"]
|
||||
[0]["type"],
|
||||
"code"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||
["text"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
|
||||
"B"
|
||||
);
|
||||
assert_eq!(
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]
|
||||
["marks"][0]["type"],
|
||||
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"]
|
||||
[0]["type"],
|
||||
"bold"
|
||||
);
|
||||
}
|
||||
@@ -9469,10 +9647,12 @@ mod tests {
|
||||
let body = serde_json::to_value(&aggregate.body).expect("body json");
|
||||
|
||||
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
|
||||
assert!(body["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(
|
||||
body["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -9494,10 +9674,12 @@ mod tests {
|
||||
.expect("save");
|
||||
|
||||
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
||||
assert!(result["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:"));
|
||||
assert!(
|
||||
result["fileVersion"]
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.starts_with("local-md:local-md:README.md:")
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -9516,6 +9698,8 @@ mod tests {
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned),
|
||||
write_intent_id: Some("intent:test:page-body-write".into()),
|
||||
save_operation_id: Some("save:test:page-body-write".into()),
|
||||
base_content_hash: Some("sha256:test-base".into()),
|
||||
content_format: "editorBlocks".into(),
|
||||
content: serde_json::json!([
|
||||
@@ -9530,6 +9714,8 @@ mod tests {
|
||||
assert_eq!(result["compatCommand"], "page.body.save");
|
||||
assert_eq!(result["contentFormat"], "editorBlocks");
|
||||
assert_eq!(result["editorSource"], "unit-test");
|
||||
assert_eq!(result["writeIntentId"], "intent:test:page-body-write");
|
||||
assert_eq!(result["saveOperationId"], "save:test:page-body-write");
|
||||
assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(saved.contains("# Written"));
|
||||
@@ -10614,10 +10800,12 @@ fn main() {}
|
||||
assert_eq!(created["grant"]["ownerUserId"], "user_owner");
|
||||
assert_eq!(created["grant"]["targetUserId"], "user_target");
|
||||
assert_eq!(created["grant"]["permission"], "write");
|
||||
assert!(created["grant"]["shareId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("folder_"));
|
||||
assert!(
|
||||
created["grant"]["shareId"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("folder_")
|
||||
);
|
||||
|
||||
let outside_error = create_user_share_grant(
|
||||
Extension(request_context("user_owner", "user")),
|
||||
@@ -10887,11 +11075,13 @@ fn main() {}
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].id, link_id);
|
||||
assert_ne!(stored[0].token_hash, "visible-token");
|
||||
assert!(state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve share link")
|
||||
.is_some());
|
||||
assert!(
|
||||
state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve share link")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let (_, Json(listed)) = get_share_links(
|
||||
State(state.clone()),
|
||||
@@ -10924,11 +11114,13 @@ fn main() {}
|
||||
.expect("share revoked broadcast delta");
|
||||
assert_eq!(revoked_delta["kind"], "control_plane_event");
|
||||
assert_eq!(revoked_delta["eventType"], "control.share.revoked");
|
||||
assert!(state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve revoked share link")
|
||||
.is_none());
|
||||
assert!(
|
||||
state
|
||||
.control_plane()
|
||||
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
|
||||
.expect("resolve revoked share link")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.control_plane()
|
||||
@@ -11132,11 +11324,13 @@ fn main() {}
|
||||
payload["workspace"]["manifest"]["ownerId"],
|
||||
"user@example.com"
|
||||
);
|
||||
assert!(payload["workspace"]["manifest"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value.as_str() == Some("markdown_edit")));
|
||||
assert!(
|
||||
payload["workspace"]["manifest"]["capabilities"]
|
||||
.as_array()
|
||||
.expect("capabilities")
|
||||
.iter()
|
||||
.any(|value| value.as_str() == Some("markdown_edit"))
|
||||
);
|
||||
ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
|
||||
.expect("owner can access managed workspace");
|
||||
|
||||
@@ -11387,11 +11581,12 @@ fn main() {}
|
||||
execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
|
||||
.expect("delete loose markdown");
|
||||
assert!(!root.join("Renamed.md").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("Renamed.md")
|
||||
.is_file());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("Renamed.md")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
|
||||
|
||||
let restored =
|
||||
@@ -12441,6 +12636,17 @@ fn main() {}
|
||||
std::fs::write(root.join("Page").join("Page.md"), "").expect("write md");
|
||||
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap");
|
||||
std::fs::write(root.join("Page").join("report.docx"), b"docx").expect("write docx");
|
||||
std::fs::write(root.join("Page").join("sheet.xlsx"), b"xlsx").expect("write xlsx");
|
||||
std::fs::write(root.join("Page").join("slides.pptx"), b"pptx").expect("write pptx");
|
||||
std::fs::write(root.join("Page").join("main.rs"), b"fn main() {}").expect("write rs");
|
||||
std::fs::write(
|
||||
root.join("Page").join("app.tsx"),
|
||||
b"export const App = () => null;",
|
||||
)
|
||||
.expect("write tsx");
|
||||
std::fs::write(root.join("Page").join("package.json"), b"{}").expect("write package");
|
||||
std::fs::write(root.join("Page").join(".env"), b"KEY=value").expect("write env");
|
||||
std::fs::write(root.join("Page").join("config.yaml"), b"name: mnote").expect("write yaml");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let snapshot =
|
||||
@@ -12456,17 +12662,60 @@ fn main() {}
|
||||
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
||||
Some("mindmap")
|
||||
);
|
||||
assert_eq!(
|
||||
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
|
||||
Some("local-file:Page/思维导图123456.json")
|
||||
);
|
||||
|
||||
let office = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("report.docx"))
|
||||
.expect("office row");
|
||||
assert_eq!(office["rowKind"].as_str(), Some("asset"));
|
||||
assert_eq!(office["iconHint"].as_str(), Some("office"));
|
||||
assert_eq!(office["iconHint"].as_str(), Some("word"));
|
||||
assert_eq!(
|
||||
office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
|
||||
Some("only_office")
|
||||
);
|
||||
assert_eq!(
|
||||
office["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
|
||||
Some("local-file:Page/report.docx")
|
||||
);
|
||||
let sheet = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("sheet.xlsx"))
|
||||
.expect("sheet row");
|
||||
assert_eq!(sheet["iconHint"].as_str(), Some("sheet"));
|
||||
let ppt = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("slides.pptx"))
|
||||
.expect("ppt row");
|
||||
assert_eq!(ppt["iconHint"].as_str(), Some("ppt"));
|
||||
let rust = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("main.rs"))
|
||||
.expect("rust row");
|
||||
assert_eq!(rust["iconHint"].as_str(), Some("code"));
|
||||
let tsx = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("app.tsx"))
|
||||
.expect("tsx row");
|
||||
assert_eq!(tsx["iconHint"].as_str(), Some("web"));
|
||||
let package_json = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("package.json"))
|
||||
.expect("package row");
|
||||
assert_eq!(package_json["iconHint"].as_str(), Some("config"));
|
||||
let env = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some(".env"))
|
||||
.expect("env row");
|
||||
assert_eq!(env["iconHint"].as_str(), Some("config"));
|
||||
let yaml = items
|
||||
.iter()
|
||||
.find(|item| item["title"].as_str() == Some("config.yaml"))
|
||||
.expect("yaml row");
|
||||
assert_eq!(yaml["iconHint"].as_str(), Some("config"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -12497,6 +12746,8 @@ fn main() {}
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: None,
|
||||
write_intent_id: Some("intent:test:buffer-save".into()),
|
||||
save_operation_id: Some("save:test:buffer-save".into()),
|
||||
base_content_hash: None,
|
||||
content_format: "editorBlocks".into(),
|
||||
content: serde_json::json!([
|
||||
@@ -12875,6 +13126,30 @@ fn main() {}
|
||||
markdown_asset["ownerDocumentId"],
|
||||
"local-md:docs~2FREADME~2FREADME.md"
|
||||
);
|
||||
let windows_path_asset = write_local_markdown_asset(
|
||||
&root_uri,
|
||||
"local-md:docs~2FREADME~2FREADME.md",
|
||||
"attachment",
|
||||
LocalUploadFile {
|
||||
name: "C:\\Users\\liaib\\Downloads\\1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx".to_string(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
.to_string(),
|
||||
bytes: b"pptx".to_vec(),
|
||||
},
|
||||
)
|
||||
.expect("upload windows path named pptx asset");
|
||||
assert_eq!(
|
||||
windows_path_asset["file_name"],
|
||||
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["sourcePath"],
|
||||
"1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
assert_eq!(
|
||||
windows_path_asset["rootRelativePath"],
|
||||
"docs/README/1768096586803-672be7a7-5cb3-454d-9b1b-6230f86ba392.pptx"
|
||||
);
|
||||
let uploaded_asset_index =
|
||||
std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json"))
|
||||
.expect("uploaded asset index");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
|
||||
use comrak::{parse_document, Arena, Options};
|
||||
use serde_json::{json, Map, Value};
|
||||
use comrak::{Arena, Options, parse_document};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{
|
||||
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -852,41 +852,55 @@ mod tests {
|
||||
.iter()
|
||||
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
.expect("home result");
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
|
||||
assert!(home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
)));
|
||||
assert!(
|
||||
home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("docs/child.md"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
assert!(
|
||||
home["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
|
||||
))
|
||||
);
|
||||
|
||||
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
|
||||
let child_search = query_local_search_index(
|
||||
@@ -915,11 +929,12 @@ mod tests {
|
||||
Some("local-mdid:child-page")
|
||||
);
|
||||
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
let mindmap_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -931,19 +946,19 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("mindmap projection");
|
||||
assert!(mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path
|
||||
assert!(
|
||||
mindmap_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("mindmap")
|
||||
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
|
||||
&& item["publicPath"].as_str().is_some_and(|path| path
|
||||
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?"))));
|
||||
&& item["publicPath"]
|
||||
.as_str()
|
||||
.is_some_and(|path| !path.starts_with("/tree?")))
|
||||
);
|
||||
let office_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
@@ -955,12 +970,14 @@ mod tests {
|
||||
false,
|
||||
)
|
||||
.expect("office projection");
|
||||
assert!(office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx")));
|
||||
assert!(
|
||||
office_projection["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["resourceType"].as_str() == Some("office")
|
||||
&& item["path"].as_str() == Some("office/report.xlsx"))
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -987,10 +1004,12 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
let child = index
|
||||
.documents
|
||||
.iter()
|
||||
@@ -1006,14 +1025,18 @@ mod tests {
|
||||
let index = read_local_search_index(&root)
|
||||
.expect("read index")
|
||||
.expect("index exists");
|
||||
assert!(!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md"));
|
||||
assert!(index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md"));
|
||||
assert!(
|
||||
!index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "docs/child.md")
|
||||
);
|
||||
assert!(
|
||||
index
|
||||
.documents
|
||||
.iter()
|
||||
.any(|document| document.path == "README.md")
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,24 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
|
||||
use crate::transport::convex::{
|
||||
execute_convex_mutation_by_name, execute_convex_query_by_name,
|
||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use axum::extract::{Multipart, Query, State};
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::http::{HeaderMap, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::{Extension, Json};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
build_runtime_command_artifact_plan,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
if !actor_id.is_empty() && actor_id != "anonymous" {
|
||||
return actor_id.to_string();
|
||||
}
|
||||
if let Ok(user) = execute_convex_query_by_name(
|
||||
if let Ok(user) = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
@@ -360,7 +360,7 @@ pub async fn upload(
|
||||
) -> Result<Response, WebError> {
|
||||
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let upload_url = execute_convex_mutation_by_name(
|
||||
let upload_url = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:generateUploadUrl",
|
||||
@@ -421,7 +421,7 @@ pub async fn upload(
|
||||
.map(|value| format!("mindmaps/{value}"));
|
||||
let id = new_asset_id();
|
||||
let kind = asset_type(&file.content_type);
|
||||
let created = execute_convex_mutation_by_name(
|
||||
let created = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:createWithStorage",
|
||||
@@ -541,7 +541,7 @@ pub async fn sign(
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let asset = execute_convex_query_by_name(
|
||||
let asset = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:getById",
|
||||
@@ -557,7 +557,7 @@ pub async fn sign(
|
||||
"资源不存在",
|
||||
));
|
||||
}
|
||||
let refreshed = execute_convex_mutation_by_name(
|
||||
let refreshed = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:refreshUrl",
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
|
||||
read_local_mindmap_data, write_local_mindmap_data,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
fetch_documents_meta_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
|
||||
RuntimeTargetWire, apply_mindmap_kernel_commands_to_value,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
|
||||
@@ -122,11 +123,14 @@ async fn resolve_mindmap_workspace_id(
|
||||
|
||||
// 思维导图 runtime 的历史请求体不一定带 workspaceId;
|
||||
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。
|
||||
let meta = fetch_documents_meta_via_convex(state.config(), context, None, document_id).await?;
|
||||
let meta =
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, document_id).await?;
|
||||
Ok(read_workspace_id_from_meta(&meta))
|
||||
}
|
||||
|
||||
fn execution_artifacts_json(execution: &crate::transport::convex::ConvexCommandExecution) -> Value {
|
||||
fn execution_artifacts_json(
|
||||
execution: &crate::transport::convex::RetiredCloudCommandExecution,
|
||||
) -> Value {
|
||||
execution
|
||||
.artifacts
|
||||
.as_ref()
|
||||
@@ -175,7 +179,7 @@ pub async fn get_mindmap(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
|
||||
let query_name = resolve_query_name(¶ms);
|
||||
let result = execute_runtime_query_via_convex(
|
||||
let result = execute_runtime_query_via_legacy_cloud(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -357,7 +361,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -378,7 +382,7 @@ pub async fn apply_mindmap_command(
|
||||
));
|
||||
}
|
||||
|
||||
let current = fetch_query_data_via_convex(
|
||||
let current = fetch_query_data_via_legacy_cloud(
|
||||
state.config(),
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -439,7 +443,7 @@ pub async fn apply_mindmap_command(
|
||||
validate_only: false,
|
||||
};
|
||||
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
effective_workspace_id.as_deref(),
|
||||
@@ -465,10 +469,10 @@ pub async fn apply_mindmap_command(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ pub async fn mindmap_object_shell(
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id
|
||||
},
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"sourceKind": source_kind.unwrap_or("local_folder"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"revision": serde_json::Value::Null,
|
||||
"conflictDetectionKey": serde_json::Value::Null,
|
||||
@@ -167,7 +167,7 @@ pub async fn mindmap_object_shell(
|
||||
"shell": "mindmap",
|
||||
"documentId": doc_id,
|
||||
"mindmapId": mindmap_id,
|
||||
"sourceKind": source_kind.unwrap_or("convex_workspace"),
|
||||
"sourceKind": source_kind.unwrap_or("local_folder"),
|
||||
"rootUri": root_uri.unwrap_or(""),
|
||||
"projection": {
|
||||
"schema": "mnote.mindmap.simple_mind_map_scene.v1",
|
||||
@@ -217,7 +217,7 @@ pub async fn mindmap_object_shell(
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(&doc_id),
|
||||
escape_html(&mindmap_id),
|
||||
escape_html(source_kind.unwrap_or("convex_workspace")),
|
||||
escape_html(source_kind.unwrap_or("local_folder")),
|
||||
escape_html(root_uri.unwrap_or("")),
|
||||
body_content,
|
||||
escape_script_json(&editor_bootstrap_json),
|
||||
@@ -326,8 +326,8 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ mod local_search_index;
|
||||
mod media;
|
||||
mod mindmap_api;
|
||||
mod mindmap_shell;
|
||||
pub(crate) mod navigation_recent;
|
||||
mod onlyoffice;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
@@ -34,14 +35,16 @@ pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||
ensure_local_path_read_access, ensure_local_workspace_access,
|
||||
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, write_local_markdown_page_body,
|
||||
};
|
||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
use axum::Router;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let hermes_base_path = state.config().hermes_base_path.clone();
|
||||
@@ -109,6 +112,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/editor/image-placeholder.svg",
|
||||
get(web_shell::editor_image_placeholder_asset),
|
||||
)
|
||||
.route("/office-preview", get(web_shell::office_preview_page))
|
||||
.route(
|
||||
"/api/office-preview/vendor/{*asset_path}",
|
||||
get(web_shell::office_preview_vendor_asset),
|
||||
)
|
||||
.route("/pdf-preview", get(web_shell::pdf_preview_page))
|
||||
.route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
|
||||
.route(
|
||||
"/api/mnote-browser-runtime/resource-open-runtime.js",
|
||||
get(web_shell::resource_open_runtime_asset),
|
||||
@@ -286,6 +296,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/sidebar/shortcuts/{shortcut_id}",
|
||||
delete(sidebar_shortcuts::delete_shortcut),
|
||||
)
|
||||
.route(
|
||||
"/api/navigation/recent",
|
||||
get(navigation_recent::list_recent).post(navigation_recent::upsert_recent),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
@@ -398,6 +412,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/buffer-state", get(documents::buffer_state))
|
||||
.route(
|
||||
"/api/documents/buffer-state/dirty",
|
||||
post(documents::mark_buffer_dirty),
|
||||
)
|
||||
.route("/api/documents/purge", post(documents::purge))
|
||||
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
@@ -440,7 +458,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/assets/upload",
|
||||
post(local_folder_source::upload_local_markdown_asset),
|
||||
post(local_folder_source::upload_local_markdown_asset)
|
||||
.layer(DefaultBodyLimit::max(128 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/files/open",
|
||||
@@ -552,9 +571,12 @@ pub fn build_router(state: AppState) -> Router {
|
||||
mod tests {
|
||||
use super::build_router;
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use crate::context::RequestContext;
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn app(enable_debug_shell_routes: bool) -> Router {
|
||||
@@ -641,6 +663,16 @@ mod tests {
|
||||
("GET", "/api/onlyoffice/proxy?u=x"),
|
||||
("POST", "/api/onlyoffice/callback"),
|
||||
("POST", "/api/onlyoffice/forcesave"),
|
||||
(
|
||||
"GET",
|
||||
"/office-preview?fileUrl=/api/local-folder/files/open&fileType=docx",
|
||||
),
|
||||
("GET", "/api/office-preview/vendor/jszip.min.js"),
|
||||
("GET", "/api/office-preview/vendor/docx-preview.min.js"),
|
||||
("GET", "/api/office-preview/vendor/xlsx.full.min.js"),
|
||||
("GET", "/api/office-preview/vendor/pptx-preview.umd.js"),
|
||||
("GET", "/pdf-preview?fileUrl=/api/local-folder/files/open"),
|
||||
("GET", "/api/pdfjs/pdf.mjs"),
|
||||
("POST", "/api/media/upload"),
|
||||
("GET", "/api/media/sign?assetId=x"),
|
||||
("POST", "/api/tree/filetree/upload-target-preflight"),
|
||||
@@ -667,6 +699,316 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_preview_page_serves_lightweight_viewer_shell() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=report.docx&fileType=docx")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains("mnote-office-preview"));
|
||||
assert!(html.contains("/api/office-preview/vendor/jszip.min.js"));
|
||||
assert!(html.contains("/api/office-preview/vendor/docx-preview.min.js"));
|
||||
assert!(html.contains("/api/office-preview/vendor/xlsx.full.min.js"));
|
||||
assert!(!html.contains("mnote-office-toolbar"));
|
||||
assert!(!html.contains("mnote-office-button"));
|
||||
assert!(!html.contains(">下载<"));
|
||||
assert!(html.contains(r#"data-page-width-content-type="word""#));
|
||||
assert!(html.contains("applyPreviewWidthPreference"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn office_preview_page_serves_pptx_lightweight_viewer_shell() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/office-preview?fileUrl=/api/local-folder/files/open&fileName=slides.pptx&fileType=pptx")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains(r#"data-page-width-content-type="ppt""#));
|
||||
assert!(html.contains("/api/office-preview/vendor/pptx-preview.umd.js"));
|
||||
assert!(html.contains("renderPptx"));
|
||||
assert!(html.contains("window.pptxPreview"));
|
||||
assert!(!html.contains("mnote-office-toolbar"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pdf_preview_page_does_not_render_visible_toolbar() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/pdf-preview?fileUrl=/api/local-folder/files/open&fileName=report.pdf")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8 html");
|
||||
assert!(html.contains("<title>report.pdf</title>"));
|
||||
assert!(!html.contains("mnote-pdf-toolbar"));
|
||||
assert!(!html.contains("mnote-pdf-title"));
|
||||
assert!(!html.contains("mnote-pdf-button"));
|
||||
assert!(html.contains(r#"data-page-width-content-type="pdf""#));
|
||||
assert!(html.contains("applyPreviewWidthPreference"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pdfjs_asset_uses_mobile_compatible_pdfjs_4() {
|
||||
let response = app(false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/pdfjs/pdf.mjs")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let source = String::from_utf8(body.to_vec()).expect("utf8 js");
|
||||
assert!(
|
||||
source.contains("pdfjsVersion = 4.10.38") || source.contains("version = \"4.10.38\"")
|
||||
);
|
||||
assert!(!source.contains("getOrInsertComputed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_updates_and_returns_effective_page_width_preferences() {
|
||||
let root_a =
|
||||
std::env::temp_dir().join(format!("mnote-page-width-a-{}", std::process::id()));
|
||||
let root_b =
|
||||
std::env::temp_dir().join(format!("mnote-page-width-b-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root_a);
|
||||
let _ = fs::remove_dir_all(&root_b);
|
||||
fs::create_dir_all(root_a.join(".mnote")).expect("root a metadata");
|
||||
fs::create_dir_all(root_b.join(".mnote")).expect("root b metadata");
|
||||
fs::write(
|
||||
root_a.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-a","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root a manifest");
|
||||
fs::write(
|
||||
root_b.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-b","ownerId":"user_page_width","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root b manifest");
|
||||
let root_a_uri = format!("file://{}", root_a.display());
|
||||
let root_b_uri = format!("file://{}", root_b.display());
|
||||
let router = app(false);
|
||||
let mut put_request = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_page_width")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "workspace-a",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_a_uri,
|
||||
"documentId": "local-md:a.md",
|
||||
"updates": {
|
||||
"pageWidth.default": {"mode": "comfortable", "custom": null},
|
||||
"pageWidth.word": {"mode": "wide", "custom": null},
|
||||
"pageWidth.excel": {"mode": "full", "custom": null}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request");
|
||||
let put_context = RequestContext::from_http_parts(
|
||||
put_request.method(),
|
||||
put_request.uri(),
|
||||
put_request.headers(),
|
||||
);
|
||||
put_request.extensions_mut().insert(put_context);
|
||||
let put_response = router
|
||||
.clone()
|
||||
.oneshot(put_request)
|
||||
.await
|
||||
.expect("put response");
|
||||
let put_status = put_response.status();
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("put body bytes");
|
||||
let put_text = String::from_utf8(put_body.to_vec()).expect("utf8 put body");
|
||||
assert_eq!(put_status, StatusCode::OK, "{put_text}");
|
||||
|
||||
let mut get_request = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-b&sourceKind=local_folder&rootUri={}&documentId=local-md:b.md", root_b_uri))
|
||||
.header("x-mnote-actor-id", "user_page_width")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let get_context = RequestContext::from_http_parts(
|
||||
get_request.method(),
|
||||
get_request.uri(),
|
||||
get_request.headers(),
|
||||
);
|
||||
get_request.extensions_mut().insert(get_context);
|
||||
let get_response = router.oneshot(get_request).await.expect("get response");
|
||||
assert_eq!(get_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(get_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body bytes");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json payload");
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["default"]["mode"],
|
||||
"comfortable"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["word"]["mode"],
|
||||
"wide"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["excel"]["mode"],
|
||||
"full"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["pageWidthPreferences"]["word"]["source"],
|
||||
"global"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root_a);
|
||||
let _ = fs::remove_dir_all(&root_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ui_preferences_api_persists_ai_preferences_per_user_and_workspace() {
|
||||
let router = app(false);
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-ai-preferences-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("root metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"workspace-ai-a","ownerId":"alice_ai_pref","createdAt":"2026-05-28T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("root manifest");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut put_request = Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/api/ui/preferences")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "alice_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "workspace-ai-a",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"documentId": "local-md:ai.md",
|
||||
"updates": {
|
||||
"ai.common.default_agent_id": "reasonix",
|
||||
"ai.common.context_refs.default_selected": {
|
||||
"current_page": true,
|
||||
"folder": true
|
||||
},
|
||||
"ai.agent.hermes.profile_id": "mnoteai"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request");
|
||||
let put_context = RequestContext::from_http_parts(
|
||||
put_request.method(),
|
||||
put_request.uri(),
|
||||
put_request.headers(),
|
||||
);
|
||||
put_request.extensions_mut().insert(put_context);
|
||||
let put_response = router
|
||||
.clone()
|
||||
.oneshot(put_request)
|
||||
.await
|
||||
.expect("put response");
|
||||
let put_status = put_response.status();
|
||||
let put_body = to_bytes(put_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("put body");
|
||||
let put_text = String::from_utf8(put_body.to_vec()).expect("put utf8");
|
||||
assert_eq!(put_status, StatusCode::OK, "{put_text}");
|
||||
|
||||
let mut alice_get = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
|
||||
.header("x-mnote-actor-id", "alice_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let alice_context = RequestContext::from_http_parts(
|
||||
alice_get.method(),
|
||||
alice_get.uri(),
|
||||
alice_get.headers(),
|
||||
);
|
||||
alice_get.extensions_mut().insert(alice_context);
|
||||
let alice_response = router
|
||||
.clone()
|
||||
.oneshot(alice_get)
|
||||
.await
|
||||
.expect("alice get response");
|
||||
assert_eq!(alice_response.status(), StatusCode::OK);
|
||||
let alice_body = to_bytes(alice_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("alice body");
|
||||
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
|
||||
"reasonix"
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]["folder"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
|
||||
"mnoteai"
|
||||
);
|
||||
|
||||
let mut bob_get = Request::builder()
|
||||
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
|
||||
.header("x-mnote-actor-id", "bob_ai_pref")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
let bob_context =
|
||||
RequestContext::from_http_parts(bob_get.method(), bob_get.uri(), bob_get.headers());
|
||||
bob_get.extensions_mut().insert(bob_context);
|
||||
let bob_response = router.oneshot(bob_get).await.expect("bob get response");
|
||||
assert_eq!(bob_response.status(), StatusCode::OK);
|
||||
let bob_body = to_bytes(bob_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("bob body");
|
||||
let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json");
|
||||
assert!(
|
||||
bob_payload["result"]["aiPreferences"]
|
||||
.as_object()
|
||||
.map(|value| value.is_empty())
|
||||
.unwrap_or(false)
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnote_browser_runtime_assets_are_explicitly_mounted() {
|
||||
for path in [
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationRecentListQuery {
|
||||
#[serde(default)]
|
||||
kind: Option<String>,
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationRecentUpsertRequest {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default, alias = "workspace_id")]
|
||||
workspace_id: Option<String>,
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
#[serde(default, alias = "source_kind")]
|
||||
source_kind: String,
|
||||
#[serde(default, alias = "root_uri")]
|
||||
root_uri: String,
|
||||
#[serde(default, alias = "relative_path")]
|
||||
relative_path: Option<String>,
|
||||
#[serde(default, alias = "document_id")]
|
||||
document_id: Option<String>,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default, alias = "metadata_json")]
|
||||
metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_recent(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<NavigationRecentListQuery>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let records = load_navigation_recent_records(
|
||||
&state,
|
||||
&context,
|
||||
&actor_id,
|
||||
query.kind.as_deref(),
|
||||
query.limit.unwrap_or(40),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"folders": records.iter().filter(|record| record.kind == "folder").map(recent_to_json).collect::<Vec<_>>(),
|
||||
"pages": records.iter().filter(|record| record.kind == "page").map(recent_to_json).collect::<Vec<_>>(),
|
||||
"items": records.iter().map(recent_to_json).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn upsert_recent(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(request): Json<NavigationRecentUpsertRequest>,
|
||||
) -> Result<Json<Value>, WebError> {
|
||||
let actor_id = require_actor_id(&state, &context)?;
|
||||
let metadata_json = normalize_metadata_json(request.metadata_json)?;
|
||||
let source_kind = request
|
||||
.source_kind
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_else(|| "local_folder".to_string());
|
||||
let root_uri = request.root_uri.trim().to_string();
|
||||
let kind = request.kind.trim().to_string();
|
||||
let document_id = request
|
||||
.document_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let relative_path = normalize_navigation_relative_path(request.relative_path.as_deref())?
|
||||
.or_else(|| {
|
||||
document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
});
|
||||
if source_kind == "local_folder" {
|
||||
let root_path = ensure_local_workspace_read_access_with_state(&state, &context, &root_uri)?;
|
||||
ensure_navigation_recent_target_exists(&root_path, &kind, relative_path.as_deref())?;
|
||||
}
|
||||
let record = state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: request.id,
|
||||
user_id: actor_id,
|
||||
workspace_id: request
|
||||
.workspace_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
kind,
|
||||
source_kind,
|
||||
root_uri,
|
||||
relative_path,
|
||||
document_id,
|
||||
title: request.title.trim().to_string(),
|
||||
metadata_json,
|
||||
})
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_upsert_failed",
|
||||
format!("写入最近访问失败: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"owner": "mnote-web",
|
||||
"recent": recent_to_json(&record),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn load_navigation_recent_records(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
actor_id: &str,
|
||||
kind: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Vec<NavigationRecentRecord> {
|
||||
state
|
||||
.control_plane()
|
||||
.list_navigation_recent(actor_id, kind, limit)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|record| navigation_recent_is_accessible(state, context, record))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn navigation_recent_is_accessible(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
record: &NavigationRecentRecord,
|
||||
) -> bool {
|
||||
if record.source_kind != "local_folder" {
|
||||
return true;
|
||||
}
|
||||
let Ok(root_path) =
|
||||
ensure_local_workspace_read_access_with_state(state, context, &record.root_uri)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let relative_path = if record
|
||||
.relative_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
let Ok(relative_path) = normalize_navigation_relative_path(record.relative_path.as_deref())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
relative_path
|
||||
} else {
|
||||
record
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
};
|
||||
let Ok(target_path) = relative_path
|
||||
.as_deref()
|
||||
.map(|path| canonical_navigation_target(&root_path, path))
|
||||
.transpose()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match record.kind.as_str() {
|
||||
"folder" => target_path
|
||||
.as_deref()
|
||||
.map(Path::is_dir)
|
||||
.unwrap_or_else(|| root_path.is_dir()),
|
||||
"page" => target_path.as_deref().map(Path::is_file).unwrap_or(false),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_navigation_recent_target_exists(
|
||||
root_path: &Path,
|
||||
kind: &str,
|
||||
relative_path: Option<&str>,
|
||||
) -> Result<(), WebError> {
|
||||
match kind {
|
||||
"folder" => {
|
||||
if let Some(path) = relative_path {
|
||||
let target = canonical_navigation_target(root_path, path)?;
|
||||
if !target.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_folder_not_found",
|
||||
"最近访问文件夹不存在",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"page" => {
|
||||
let Some(path) = relative_path else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_page_path_required",
|
||||
"最近访问页面缺少相对路径",
|
||||
));
|
||||
};
|
||||
let target = canonical_navigation_target(root_path, path)?;
|
||||
if !target.is_file() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_page_not_found",
|
||||
"最近访问页面不存在",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_navigation_target(root_path: &Path, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let relative_path = normalize_navigation_relative_path(Some(relative_path))?
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.map(|path| root_path.join(path))
|
||||
.unwrap_or_else(|| root_path.to_path_buf());
|
||||
let canonical_target = relative_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_target_unavailable",
|
||||
format!("最近访问目标不可用: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !canonical_target.starts_with(root_path) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_root_escape",
|
||||
"最近访问路径不能越过授权目录",
|
||||
));
|
||||
}
|
||||
Ok(canonical_target)
|
||||
}
|
||||
|
||||
fn normalize_navigation_relative_path(value: Option<&str>) -> Result<Option<String>, WebError> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let normalized = value.replace('\\', "/");
|
||||
let raw_path = Path::new(&normalized);
|
||||
if raw_path.is_absolute()
|
||||
|| raw_path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"navigation_recent_root_escape",
|
||||
"最近访问路径不能越过授权目录",
|
||||
));
|
||||
}
|
||||
let cleaned = normalized
|
||||
.split('/')
|
||||
.filter(|segment| !segment.trim().is_empty() && *segment != ".")
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
Ok(Some(cleaned).filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
Some(
|
||||
document_id
|
||||
.trim()
|
||||
.strip_prefix("local-md:")?
|
||||
.replace("~2F", "/"),
|
||||
)
|
||||
}
|
||||
|
||||
fn require_actor_id(state: &AppState, context: &RequestContext) -> Result<String, WebError> {
|
||||
current_actor_id(state, context)
|
||||
.filter(|actor_id| !actor_id.trim().is_empty() && actor_id.trim() != "anonymous")
|
||||
.ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"navigation_recent_auth_required",
|
||||
"最近访问需要登录用户",
|
||||
)
|
||||
.with_context(context)
|
||||
})
|
||||
}
|
||||
|
||||
fn recent_to_json(record: &NavigationRecentRecord) -> Value {
|
||||
json!({
|
||||
"id": record.id,
|
||||
"userId": record.user_id,
|
||||
"workspaceId": record.workspace_id,
|
||||
"kind": record.kind,
|
||||
"sourceKind": record.source_kind,
|
||||
"rootUri": record.root_uri,
|
||||
"relativePath": record.relative_path,
|
||||
"documentId": record.document_id,
|
||||
"title": record.title,
|
||||
"status": record.status,
|
||||
"metadata": serde_json::from_str::<Value>(&record.metadata_json).unwrap_or(Value::Null),
|
||||
"visitedAt": record.visited_at,
|
||||
"createdAt": record.created_at,
|
||||
"updatedAt": record.updated_at,
|
||||
"revision": record.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_metadata_json(metadata_json: Option<String>) -> Result<String, WebError> {
|
||||
let metadata = metadata_json
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| serde_json::from_str::<Value>(value))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"navigation_recent_metadata_invalid",
|
||||
format!("最近访问 metadataJson 必须是 JSON 对象: {error}"),
|
||||
)
|
||||
})?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let normalized = if metadata.is_object() {
|
||||
metadata
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
serde_json::to_string(&normalized)
|
||||
.map_err(|error| WebError::internal(format!("最近访问 metadataJson 序列化失败: {error}")))
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
impl EmptyStringExt for String {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
|
||||
if self.trim().is_empty() {
|
||||
fallback()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn test_state() -> AppState {
|
||||
AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
||||
enable_legacy_next_compat: false,
|
||||
enable_debug_shell_routes: false,
|
||||
enable_editor_actor: true,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("{name}-{}", uuid::Uuid::new_v4().simple()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_requires_logged_in_actor() {
|
||||
let response = build_app(test_state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_post_and_get_returns_folder_and_page_groups() {
|
||||
let root = temp_root("mnote-navigation-recent-api");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
let app = build_app(state);
|
||||
|
||||
let folder_body = json!({
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "docs",
|
||||
"title": "docs"
|
||||
})
|
||||
.to_string();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(folder_body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let page_body = json!({
|
||||
"kind": "page",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "docs/Plan.md",
|
||||
"documentId": "local-md:docs~2FPlan.md",
|
||||
"title": "Plan"
|
||||
})
|
||||
.to_string();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(page_body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["folders"][0]["relativePath"], "docs");
|
||||
assert_eq!(payload["pages"][0]["documentId"], "local-md:docs~2FPlan.md");
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_list_filters_deleted_local_paths() {
|
||||
let root = temp_root("mnote-navigation-recent-filter-deleted");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("Plan.md"), "# Plan\n").expect("write plan");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "page".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("docs/Plan.md".to_string()),
|
||||
document_id: Some("local-md:docs~2FPlan.md".to_string()),
|
||||
title: "Plan".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert recent");
|
||||
std::fs::remove_file(root.join("docs").join("Plan.md")).expect("delete plan");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["pages"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_post_rejects_root_escape_relative_path() {
|
||||
let root = temp_root("mnote-navigation-recent-reject-escape");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
let app = build_app(state);
|
||||
let body = json!({
|
||||
"kind": "folder",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": "../",
|
||||
"title": "escape"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/navigation/recent")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(body))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("navigation_recent_root_escape")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_recent_list_filters_root_escape_records() {
|
||||
let root = temp_root("mnote-navigation-recent-filter-escape");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let state = test_state();
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_user(UpsertUserInput {
|
||||
id: Some("user_real".to_string()),
|
||||
email: Some("user_real@example.com".to_string()),
|
||||
username: "user_real".to_string(),
|
||||
display_name: "user_real".to_string(),
|
||||
role: None,
|
||||
password_hash: None,
|
||||
})
|
||||
.expect("upsert user");
|
||||
state
|
||||
.control_plane()
|
||||
.upsert_navigation_recent(UpsertNavigationRecentInput {
|
||||
id: None,
|
||||
user_id: "user_real".to_string(),
|
||||
workspace_id: None,
|
||||
kind: "folder".to_string(),
|
||||
source_kind: "local_folder".to_string(),
|
||||
root_uri: root_uri.clone(),
|
||||
relative_path: Some("../".to_string()),
|
||||
document_id: None,
|
||||
title: "escape".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
})
|
||||
.expect("insert escaped recent through store");
|
||||
|
||||
let response = build_app(state)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/navigation/recent")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["folders"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(payload["items"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,29 @@ use crate::app::AppConfig;
|
||||
use crate::app::AppState;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{
|
||||
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
|
||||
OnlyOfficeProxyPreparationInput,
|
||||
OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback,
|
||||
prepare_proxy_request, sign_config,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::Engine;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::upgrade::Upgraded;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
|
||||
use tokio_tungstenite::tungstenite::protocol::Role;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
|
||||
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
|
||||
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
|
||||
@@ -841,8 +841,7 @@ pub async fn proxy(
|
||||
onlyoffice_storage_host_override: env_or_dotenv(
|
||||
"NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE",
|
||||
),
|
||||
convex_origin: proxy_origin_env("CONVEX_SELF_HOSTED_URL")
|
||||
.or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")),
|
||||
convex_origin: None,
|
||||
supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
.or_else(|| env_or_dotenv("SUPABASE_ANON_KEY")),
|
||||
})
|
||||
@@ -1706,9 +1705,10 @@ mod tests {
|
||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||
let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
|
||||
assert!(key.len() <= 128);
|
||||
assert!(key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||
assert!(
|
||||
key.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
||||
);
|
||||
assert!(key.starts_with("asset_1_"));
|
||||
}
|
||||
|
||||
@@ -1722,9 +1722,10 @@ mod tests {
|
||||
);
|
||||
assert!(key.len() <= 128);
|
||||
assert!(key.starts_with("mnote_"));
|
||||
assert!(key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
|
||||
assert!(
|
||||
key.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
|
||||
);
|
||||
assert!(!key.contains('/'));
|
||||
assert!(!key.contains(':'));
|
||||
assert!(!key.contains('重'));
|
||||
@@ -2024,8 +2025,11 @@ mod tests {
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"));
|
||||
assert!(
|
||||
request.starts_with(
|
||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
||||
)
|
||||
);
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
@@ -2080,8 +2084,10 @@ mod tests {
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
|
||||
assert!(
|
||||
request
|
||||
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
@@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
@@ -952,14 +952,18 @@ mod tests {
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段"));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段")
|
||||
);
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123")
|
||||
);
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_query_plan;
|
||||
use crate::transport::convex::execute_retired_query_plan;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
|
||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||
RuntimeSourceWire,
|
||||
BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request,
|
||||
execute_runtime_input, execute_runtime_query,
|
||||
};
|
||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||
use serde_json::Value;
|
||||
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||
|
||||
pub fn resolve_effective_workspace_id(
|
||||
context: &RequestContext,
|
||||
@@ -155,24 +154,24 @@ pub fn build_documents_meta_query_plan(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_query_data_via_convex(
|
||||
pub async fn fetch_query_data_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?;
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub async fn fetch_documents_meta_via_convex(
|
||||
pub async fn fetch_documents_meta_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
document_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
execute_retired_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_against_data(
|
||||
@@ -189,13 +188,14 @@ pub fn execute_runtime_query_against_data(
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_query_via_convex(
|
||||
pub async fn execute_runtime_query_via_legacy_cloud(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data =
|
||||
fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
fetch_query_data_via_legacy_cloud(config, context, effective_workspace_id, query.clone())
|
||||
.await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, runtime_context,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access, execute_local_tree_command,
|
||||
};
|
||||
use crate::transport::convex::{
|
||||
execute_convex_mutation_by_name, execute_convex_query_by_name,
|
||||
execute_retired_mutation_by_name, execute_retired_query_by_name,
|
||||
persist_runtime_command_artifacts,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::Json;
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire,
|
||||
RuntimeTargetWire, build_runtime_command_artifact_plan,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
@@ -118,7 +118,7 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if has_auth_cookie {
|
||||
if let Ok(user) = execute_convex_query_by_name(
|
||||
if let Ok(user) = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"users:currentUser",
|
||||
@@ -372,7 +372,7 @@ async fn fetch_document_workspace_id(
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
) -> Option<String> {
|
||||
execute_convex_query_by_name(
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"documents:getMeta",
|
||||
@@ -401,7 +401,7 @@ async fn fetch_table_meta(
|
||||
user_id: &str,
|
||||
table_id: &str,
|
||||
) -> Option<Value> {
|
||||
execute_convex_query_by_name(
|
||||
execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"tables:get",
|
||||
@@ -479,7 +479,7 @@ async fn fetch_media_asset_meta(
|
||||
user_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
let assets = execute_convex_query_by_name(
|
||||
let assets = execute_retired_query_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"mediaAssets:listByIds",
|
||||
@@ -606,7 +606,7 @@ pub async fn media_batch(
|
||||
body.new_name.as_deref().unwrap_or_default(),
|
||||
"newName",
|
||||
)?;
|
||||
execute_convex_mutation_by_name(
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
@@ -629,7 +629,7 @@ pub async fn media_batch(
|
||||
body.target_document_id.as_deref().unwrap_or_default(),
|
||||
"targetDocumentId",
|
||||
)?;
|
||||
execute_convex_mutation_by_name(
|
||||
execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:patchById",
|
||||
@@ -646,7 +646,7 @@ pub async fn media_batch(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let command_result = execute_runtime_command_via_convex_with_artifacts(
|
||||
let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -712,7 +712,7 @@ pub async fn media_purge(
|
||||
"assetId": asset_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -729,7 +729,7 @@ pub async fn media_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mediaAssets:emptyTrashByWorkspace",
|
||||
@@ -812,7 +812,7 @@ pub async fn mindmap_delete(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -897,7 +897,7 @@ pub async fn mindmap_trash_action(
|
||||
"mindmapId": mindmap_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -916,7 +916,7 @@ pub async fn mindmap_empty_trash(
|
||||
Json(body): Json<WorkspaceTrashRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"mindmaps:emptyTrashByWorkspace",
|
||||
@@ -980,7 +980,7 @@ pub async fn table_create(
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
})?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:create",
|
||||
@@ -1087,7 +1087,7 @@ async fn table_action(
|
||||
"tableId": table_id,
|
||||
}),
|
||||
);
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
workspace_id.as_deref(),
|
||||
@@ -1108,7 +1108,7 @@ pub async fn table_empty_trash(
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let user_id = current_user_id(&state, &context).await;
|
||||
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?;
|
||||
let result = execute_convex_mutation_by_name(
|
||||
let result = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
&context,
|
||||
"tables:emptyTrashByWorkspace",
|
||||
@@ -1148,8 +1148,8 @@ pub async fn table_empty_trash(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::Request;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -1311,11 +1311,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(payload["result"]["sourceKind"], "local_folder");
|
||||
assert!(!root.join("Page").join("map.mindmap.json").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
|
||||
original_markdown,
|
||||
@@ -1421,11 +1422,13 @@ mod tests {
|
||||
purged_payload["result"]["canonicalCommand"],
|
||||
"tree.resource.purge"
|
||||
);
|
||||
assert!(!root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists());
|
||||
assert!(
|
||||
!root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("map.mindmap.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_convex,
|
||||
execute_runtime_query_against_data, execute_runtime_query_via_legacy_cloud,
|
||||
resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::web_shell::load_sidebar_tree_html;
|
||||
use crate::routes::{local_folder_source, local_search_index};
|
||||
use crate::ssr::pages::search::SearchPage;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
|
||||
const HEADER_QUERY_NAME: &str = "x-query-name";
|
||||
@@ -417,7 +417,7 @@ async fn load_search_results_with_filters(
|
||||
"customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()),
|
||||
}),
|
||||
};
|
||||
match execute_runtime_query_via_convex(
|
||||
match execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
@@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -788,31 +788,40 @@ mod tests {
|
||||
.expect("home result");
|
||||
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
|
||||
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
|
||||
assert!(home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha")));
|
||||
assert!(home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily")));
|
||||
assert!(home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists());
|
||||
assert!(payload["recent"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
assert!(
|
||||
home["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag.as_str() == Some("alpha"))
|
||||
);
|
||||
assert!(
|
||||
home["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|link| link.as_str() == Some("Daily"))
|
||||
);
|
||||
assert!(
|
||||
home["resourceRefs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
|
||||
);
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("index")
|
||||
.join("search-index.json")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
payload["recent"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -921,11 +930,13 @@ mod tests {
|
||||
backlinks_payload["meta"]["queryName"].as_str(),
|
||||
Some("search.local_index.backlinks")
|
||||
);
|
||||
assert!(backlinks_payload["result"]["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
|
||||
assert!(
|
||||
backlinks_payload["result"]["backlinks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
|
||||
);
|
||||
|
||||
let tags_response = app()
|
||||
.oneshot(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use control_plane::session_token_hash;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
|
||||
use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
|
||||
@@ -2,12 +2,12 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -2,11 +2,11 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_against_data, fetch_query_data_via_convex,
|
||||
execute_runtime_query_against_data, fetch_query_data_via_legacy_cloud,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSnapshotSpec<'a> {
|
||||
@@ -38,7 +38,7 @@ pub async fn load_sidebar_dataset(
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
) -> Result<Value, WebError> {
|
||||
fetch_query_data_via_convex(
|
||||
fetch_query_data_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(workspace_id),
|
||||
|
||||
@@ -2,15 +2,15 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
StreamChangeKind, StreamSnapshotQuery,
|
||||
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload,
|
||||
build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
@@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -2,16 +2,16 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, resolve_effective_workspace_id,
|
||||
execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
|
||||
ProjectionSnapshotSpec,
|
||||
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
|
||||
subtree_query,
|
||||
};
|
||||
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
|
||||
"page.body.save",
|
||||
@@ -627,7 +627,7 @@ pub async fn load_stream_overview(
|
||||
let effective_workspace_id =
|
||||
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
|
||||
.expect("workspace_required 已确保存在");
|
||||
let overview = execute_runtime_query_via_convex(
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -670,7 +670,7 @@ pub async fn load_stream_snapshot(
|
||||
}
|
||||
};
|
||||
|
||||
let overview = execute_runtime_query_via_convex(
|
||||
let overview = execute_runtime_query_via_legacy_cloud(
|
||||
config,
|
||||
context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -701,8 +701,9 @@ pub async fn load_stream_snapshot(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
|
||||
resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
|
||||
resolve_stream_scope,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
@@ -3,45 +3,45 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order,
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state,
|
||||
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri, LocalAccessMode,
|
||||
LocalAccessMode, ensure_local_workspace_access_with_state,
|
||||
ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
|
||||
};
|
||||
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
|
||||
use crate::transport::convex::execute_convex_mutation_by_name;
|
||||
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
|
||||
use crate::transport::convex::execute_retired_mutation_by_name;
|
||||
use crate::tree_shell::filetree_renderer::{
|
||||
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
|
||||
FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html,
|
||||
};
|
||||
use crate::tree_shell::filetree_selection::FileTreeSelectionState;
|
||||
use crate::tree_shell::page_renderer::{
|
||||
render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow,
|
||||
PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html,
|
||||
};
|
||||
use crate::tree_shell::picker_renderer::{
|
||||
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
|
||||
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
|
||||
};
|
||||
use crate::tree_shell::renderer_input::{
|
||||
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
|
||||
TreeShellRendererInput,
|
||||
};
|
||||
use crate::tree_shell::runtime_api::{
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
|
||||
TreeShellRuntimeResult,
|
||||
TreeShellRuntimeRequest, TreeShellRuntimeResult,
|
||||
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use bridge_runtime::RuntimeCommandEnvelopeWire;
|
||||
use core_protocol::KernelProjectionKind;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -137,6 +137,69 @@ impl TreeCommandEnvelopeContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TreeCompatAliasCatalogEntry {
|
||||
pub compat_command: &'static str,
|
||||
pub preferred_command: &'static str,
|
||||
pub source_kind: &'static str,
|
||||
pub retained_for: &'static str,
|
||||
pub retirement_condition: &'static str,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub const TREE_DOCUMENT_COMPAT_ALIAS_CATALOG: &[TreeCompatAliasCatalogEntry] = &[
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.create",
|
||||
preferred_command: "tree.node.create",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.create",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.title.update",
|
||||
preferred_command: "tree.node.rename",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.rename",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.move",
|
||||
preferred_command: "tree.subtree.move",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.move",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.delete",
|
||||
preferred_command: "tree.node.archive",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.archive",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.restore",
|
||||
preferred_command: "tree.node.restore",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.restore",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.purge",
|
||||
preferred_command: "tree.node.purge",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.node.purge",
|
||||
},
|
||||
TreeCompatAliasCatalogEntry {
|
||||
compat_command: "documents.copy_tree",
|
||||
preferred_command: "tree.subtree.copy",
|
||||
source_kind: "convex_workspace",
|
||||
retained_for: "legacy cloud / remote callers still emitting documents.*",
|
||||
retirement_condition: "all cloud and remote callers emit tree.subtree.copy",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TreeCommandRequest {
|
||||
Create {
|
||||
@@ -2074,7 +2137,7 @@ async fn resolve_tree_create_workspace_id(
|
||||
|
||||
if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let parent_meta =
|
||||
fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?;
|
||||
fetch_documents_meta_via_legacy_cloud(state.config(), context, None, parent_id).await?;
|
||||
if let Some(workspace_id) = parent_meta
|
||||
.get("workspace_id")
|
||||
.or_else(|| parent_meta.get("workspaceId"))
|
||||
@@ -2086,7 +2149,7 @@ async fn resolve_tree_create_workspace_id(
|
||||
}
|
||||
}
|
||||
|
||||
let bootstrap = execute_convex_mutation_by_name(
|
||||
let bootstrap = execute_retired_mutation_by_name(
|
||||
state.config(),
|
||||
context,
|
||||
"workspaces:ensureDefaultWorkspace",
|
||||
@@ -2201,7 +2264,15 @@ pub async fn tree_command(
|
||||
.with_context(&context)
|
||||
.with_header("x-error-phase", "tree_command_decode")
|
||||
})?;
|
||||
let envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
|
||||
let mut envelope_context = TreeCommandEnvelopeContext::from_envelope(&raw_request);
|
||||
if envelope_context.source_kind.is_none()
|
||||
&& envelope_context
|
||||
.root_uri
|
||||
.as_deref()
|
||||
.is_some_and(|root_uri| root_uri.trim().starts_with("file://"))
|
||||
{
|
||||
envelope_context.source_kind = Some("local_folder".into());
|
||||
}
|
||||
let request = match raw_request.action.trim() {
|
||||
"create" => TreeCommandRequest::Create {
|
||||
workspace_id: raw_request.workspace_id,
|
||||
@@ -2453,7 +2524,7 @@ pub async fn tree_command(
|
||||
command_wire.preflight_data =
|
||||
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?);
|
||||
}
|
||||
let execution = execute_runtime_command_via_convex_with_artifacts(
|
||||
let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
|
||||
&state,
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -2513,10 +2584,10 @@ mod tests {
|
||||
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
|
||||
|
||||
use super::{
|
||||
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||
TreeCommandRequest,
|
||||
TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
|
||||
collect_filetree_render_rows, create_command_wire,
|
||||
};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
@@ -2540,7 +2611,7 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"table","file_name":"预算.table","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()),
|
||||
mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
@@ -2681,14 +2752,20 @@ mod tests {
|
||||
assert!(html.contains("data-rust-action=\"toggle\""));
|
||||
assert!(html.contains("data-testid=\"tree-node-toggle\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
|
||||
@@ -2713,8 +2790,11 @@ mod tests {
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains("id=\"tree-shell-state\""));
|
||||
assert!(html
|
||||
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
html.contains(
|
||||
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
|
||||
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML"
|
||||
@@ -2749,17 +2829,23 @@ mod tests {
|
||||
assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
|
||||
assert!(html.contains("tabindex=\""));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
||||
);
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
|
||||
}
|
||||
|
||||
@@ -2799,12 +2885,18 @@ mod tests {
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
||||
);
|
||||
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||
.contains("function buildFileTreeMenuTarget(context"));
|
||||
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||
.contains("function createTreeShellFileTreeDndRuntime(context)"));
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS
|
||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||
.contains("function buildFileTreeMenuTarget(context")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_FILETREE_DND_RUNTIME_JS
|
||||
.contains("function createTreeShellFileTreeDndRuntime(context)")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RENDER_RUNTIME_JS
|
||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
|
||||
);
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))")
|
||||
);
|
||||
@@ -2812,8 +2904,10 @@ mod tests {
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null"));
|
||||
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
|
||||
assert!(TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
assert!(
|
||||
TREE_SHELL_RUNTIME_JS
|
||||
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3026,8 +3120,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint(
|
||||
) {
|
||||
async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint()
|
||||
{
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
@@ -3142,11 +3236,12 @@ mod tests {
|
||||
String::from_utf8_lossy(&move_body)
|
||||
);
|
||||
assert!(!root.join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
|
||||
let moved_document_id = move_payload["result"]["documentId"]
|
||||
.as_str()
|
||||
@@ -3186,11 +3281,12 @@ mod tests {
|
||||
.as_str()
|
||||
.expect("copied document id")
|
||||
.to_string();
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面 2")
|
||||
.join("重命名页面 2.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面 2")
|
||||
.join("重命名页面 2.md")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let folder_response = app()
|
||||
.oneshot(
|
||||
@@ -3223,12 +3319,13 @@ mod tests {
|
||||
.expect("response");
|
||||
assert_eq!(delete_response.status(), StatusCode::OK);
|
||||
assert!(!root.join("docs").join("重命名页面").exists());
|
||||
assert!(root
|
||||
.join(".mnote")
|
||||
.join("trash")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join(".mnote")
|
||||
.join("trash")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
assert!(root.join(".mnote").join("trash-index.json").exists());
|
||||
assert!(!root.join(".mnote").join("page-ids.json").exists());
|
||||
|
||||
@@ -3255,11 +3352,12 @@ mod tests {
|
||||
"{}",
|
||||
String::from_utf8_lossy(&restore_body)
|
||||
);
|
||||
assert!(root
|
||||
.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists());
|
||||
assert!(
|
||||
root.join("docs")
|
||||
.join("重命名页面")
|
||||
.join("重命名页面.md")
|
||||
.exists()
|
||||
);
|
||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||
assert_eq!(
|
||||
restore_payload["result"]["documentId"].as_str(),
|
||||
@@ -3353,7 +3451,21 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(restore_response.status(), StatusCode::OK);
|
||||
let restore_status = restore_response.status();
|
||||
let restore_body = axum::body::to_bytes(restore_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("restore body");
|
||||
assert_eq!(
|
||||
restore_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&restore_body)
|
||||
);
|
||||
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
|
||||
assert_eq!(
|
||||
restore_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.restore"
|
||||
);
|
||||
assert!(root.join("docs").join("photo.png").exists());
|
||||
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
||||
|
||||
@@ -3364,13 +3476,28 @@ mod tests {
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
||||
r#"{{"action":"delete","rootUri":"{root_uri}","documentId":"{asset_id}"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(delete_again_response.status(), StatusCode::OK);
|
||||
let delete_again_status = delete_again_response.status();
|
||||
let delete_again_body = axum::body::to_bytes(delete_again_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("delete again body");
|
||||
assert_eq!(
|
||||
delete_again_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&delete_again_body)
|
||||
);
|
||||
let delete_again_payload: Value =
|
||||
serde_json::from_slice(&delete_again_body).expect("delete again json");
|
||||
assert_eq!(
|
||||
delete_again_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.archive"
|
||||
);
|
||||
|
||||
let purge_response = app()
|
||||
.oneshot(
|
||||
@@ -3385,7 +3512,21 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(purge_response.status(), StatusCode::OK);
|
||||
let purge_status = purge_response.status();
|
||||
let purge_body = axum::body::to_bytes(purge_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("purge body");
|
||||
assert_eq!(
|
||||
purge_status,
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&purge_body)
|
||||
);
|
||||
let purge_payload: Value = serde_json::from_slice(&purge_body).expect("purge json");
|
||||
assert_eq!(
|
||||
purge_payload["result"]["execution"]["canonicalCommand"],
|
||||
"tree.resource.purge"
|
||||
);
|
||||
assert!(!root.join("docs").join("photo.png").exists());
|
||||
assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
|
||||
let trash_index_after =
|
||||
@@ -3432,10 +3573,12 @@ mod tests {
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "local_folder_root_escape");
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("root"));
|
||||
assert!(
|
||||
payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("root")
|
||||
);
|
||||
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
|
||||
assert_eq!(
|
||||
headers
|
||||
@@ -3604,8 +3747,10 @@ mod tests {
|
||||
assert!(filetree_html.contains(
|
||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||
));
|
||||
assert!(filetree_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
filetree_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
||||
);
|
||||
assert!(filetree_html.contains(
|
||||
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
|
||||
));
|
||||
@@ -3633,8 +3778,10 @@ mod tests {
|
||||
assert!(picker_html.contains(
|
||||
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
|
||||
));
|
||||
assert!(picker_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
|
||||
assert!(
|
||||
picker_html
|
||||
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4050,11 +4197,13 @@ mod tests {
|
||||
Some("convex://workspace/ws_demo")
|
||||
);
|
||||
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
|
||||
assert!(create_wire
|
||||
.source
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "execute-command"));
|
||||
assert!(
|
||||
create_wire
|
||||
.source
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|capability| capability == "execute-command")
|
||||
);
|
||||
|
||||
let rename_wire = create_command_wire(
|
||||
&context,
|
||||
@@ -4242,4 +4391,42 @@ mod tests {
|
||||
assert_eq!(tree_move_plan.function_name, "documents:move");
|
||||
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() {
|
||||
let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG;
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.compat_command.starts_with("documents."))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.preferred_command.starts_with("tree."))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.source_kind == "convex_workspace")
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retained_for.contains("legacy cloud"))
|
||||
);
|
||||
assert!(
|
||||
aliases
|
||||
.iter()
|
||||
.all(|entry| entry.retirement_condition.contains("emit tree."))
|
||||
);
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.delete"
|
||||
&& entry.preferred_command == "tree.node.archive"
|
||||
}));
|
||||
assert!(aliases.iter().any(|entry| {
|
||||
entry.compat_command == "documents.copy_tree"
|
||||
&& entry.preferred_command == "tree.subtree.copy"
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
|
||||
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
|
||||
@@ -73,7 +73,11 @@ pub(crate) async fn get_tree_view_state(
|
||||
)?;
|
||||
let preferences = state
|
||||
.control_plane()
|
||||
.list_user_ui_preferences(&actor_id, Some(&scope.workspace_id), Some(&scope.source_kind))
|
||||
.list_user_ui_preferences(
|
||||
&actor_id,
|
||||
Some(&scope.workspace_id),
|
||||
Some(&scope.source_kind),
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
|
||||
.with_context(&context)
|
||||
@@ -206,13 +210,10 @@ fn normalize_state(
|
||||
scope: &TreeViewStateScope,
|
||||
value: Value,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut object = value
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let mut object = value.as_object().cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
|
||||
.with_context(context)
|
||||
})?;
|
||||
let schema_version = object
|
||||
.get("schemaVersion")
|
||||
.and_then(Value::as_i64)
|
||||
@@ -247,7 +248,10 @@ fn normalize_state(
|
||||
object.insert("scope".to_string(), json!(scope.scope));
|
||||
object.insert(
|
||||
"expandedIds".to_string(),
|
||||
json!(bounded_string_array(object.get("expandedIds"), MAX_STATE_ITEMS)),
|
||||
json!(bounded_string_array(
|
||||
object.get("expandedIds"),
|
||||
MAX_STATE_ITEMS
|
||||
)),
|
||||
);
|
||||
object.insert(
|
||||
"expandedRelativePaths".to_string(),
|
||||
@@ -261,11 +265,13 @@ fn normalize_state(
|
||||
}
|
||||
object.insert(
|
||||
"scrollTop".to_string(),
|
||||
json!(object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)),
|
||||
json!(
|
||||
object
|
||||
.get("scrollTop")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.unwrap_or(0.0)
|
||||
),
|
||||
);
|
||||
if !object.contains_key("updatedAtMs") {
|
||||
object.insert("updatedAtMs".to_string(), json!(0));
|
||||
|
||||
@@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id;
|
||||
use crate::routes::local_folder_source::{
|
||||
local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Map};
|
||||
use serde_json::{Map, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
|
||||
@@ -58,9 +58,33 @@ struct PagePreferenceScope {
|
||||
struct EffectivePagePreferences {
|
||||
scope: PagePreferenceScope,
|
||||
page_options: PageOptions,
|
||||
page_width_preferences: BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: BTreeMap<String, Value>,
|
||||
sources: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct EffectivePageWidthPreference {
|
||||
mode: String,
|
||||
custom: Option<String>,
|
||||
resolved_mode: String,
|
||||
resolved_custom: Option<String>,
|
||||
css_max_width: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PageWidthPreferenceValue {
|
||||
mode: String,
|
||||
custom: Option<String>,
|
||||
}
|
||||
|
||||
const PAGE_WIDTH_CONTENT_TYPES: [&str; 7] = [
|
||||
"default", "markdown", "word", "pdf", "excel", "ppt", "mindmap",
|
||||
];
|
||||
|
||||
pub(crate) async fn effective_preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -220,30 +244,57 @@ fn resolve_effective_page_preferences(
|
||||
page_options.hide_title_header = true;
|
||||
}
|
||||
let mut sources = BTreeMap::new();
|
||||
apply_preference_records(&mut page_options, &mut sources, &scope, &preferences)?;
|
||||
let mut page_width_preferences = default_page_width_preferences();
|
||||
let mut ai_preferences = BTreeMap::new();
|
||||
apply_preference_records(
|
||||
&mut page_options,
|
||||
&mut page_width_preferences,
|
||||
&mut ai_preferences,
|
||||
&mut sources,
|
||||
&scope,
|
||||
&preferences,
|
||||
)?;
|
||||
resolve_page_width_preferences(&mut page_width_preferences);
|
||||
Ok(EffectivePagePreferences {
|
||||
scope,
|
||||
page_options,
|
||||
page_width_preferences,
|
||||
ai_preferences,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_preference_records(
|
||||
page_options: &mut PageOptions,
|
||||
page_width_preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
|
||||
ai_preferences: &mut BTreeMap<String, Value>,
|
||||
sources: &mut BTreeMap<String, String>,
|
||||
scope: &PagePreferenceScope,
|
||||
preferences: &[UserUiPreferenceRecord],
|
||||
) -> Result<(), WebError> {
|
||||
for scope_kind in ["global", "source_family", "workspace", "document"] {
|
||||
let mut scope_kinds = vec![
|
||||
"global".to_string(),
|
||||
"source_family".to_string(),
|
||||
"workspace".to_string(),
|
||||
"document".to_string(),
|
||||
];
|
||||
for preference in preferences {
|
||||
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
|
||||
{
|
||||
scope_kinds.push(preference.scope_kind.clone());
|
||||
}
|
||||
}
|
||||
for scope_kind in scope_kinds {
|
||||
for preference in preferences
|
||||
.iter()
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind)
|
||||
.filter(|preference| preference.scope_kind.trim() == scope_kind.as_str())
|
||||
{
|
||||
let scope_matches = match scope_kind {
|
||||
let scope_matches = match scope_kind.as_str() {
|
||||
"global" => preference.scope_id.trim() == "default",
|
||||
"source_family" => preference.scope_id.trim() == scope.source_family,
|
||||
"workspace" => preference.scope_id.trim() == scope.workspace_id,
|
||||
"document" => preference.scope_id.trim() == scope.document_id,
|
||||
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
|
||||
_ => false,
|
||||
};
|
||||
if !scope_matches {
|
||||
@@ -255,6 +306,22 @@ fn apply_preference_records(
|
||||
preference.key
|
||||
))
|
||||
})?;
|
||||
if preference.key.starts_with("ai.common.") || preference.key.starts_with("ai.agent.") {
|
||||
ai_preferences.insert(preference.key.clone(), value);
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
|
||||
if let Some(normalized) = normalize_page_width_preference(content_type, &value) {
|
||||
if let Some(preference_value) = page_width_preferences.get_mut(content_type) {
|
||||
preference_value.mode = normalized.mode;
|
||||
preference_value.custom = normalized.custom;
|
||||
preference_value.source = scope_kind.to_string();
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if apply_page_option_value(page_options, &preference.key, &value) {
|
||||
sources.insert(preference.key.clone(), scope_kind.to_string());
|
||||
}
|
||||
@@ -291,6 +358,19 @@ fn page_preference_scope(
|
||||
}
|
||||
|
||||
fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(String, String)> {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.starts_with("ai.common.") {
|
||||
return Some(("ai.common".to_string(), scope.workspace_id.clone()));
|
||||
}
|
||||
if let Some(rest) = trimmed.strip_prefix("ai.agent.") {
|
||||
let agent = rest.split('.').next().unwrap_or_default().trim();
|
||||
if !agent.is_empty() {
|
||||
return Some((format!("ai.agent.{agent}"), scope.workspace_id.clone()));
|
||||
}
|
||||
}
|
||||
if page_width_content_type_for_key(key).is_some() {
|
||||
return Some(("global".to_string(), "default".to_string()));
|
||||
}
|
||||
match key.trim() {
|
||||
"hideTitleHeader" | "hide_title_header" => {
|
||||
Some(("source_family".to_string(), scope.source_family.clone()))
|
||||
@@ -321,7 +401,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
|
||||
}
|
||||
|
||||
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
Some(workspace_id.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -329,7 +409,7 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
|
||||
}
|
||||
|
||||
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
|
||||
if scope_kind == "workspace" || scope_kind == "document" {
|
||||
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
|
||||
Some(source_kind.to_string())
|
||||
} else {
|
||||
None
|
||||
@@ -338,6 +418,10 @@ fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Opti
|
||||
|
||||
fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({}));
|
||||
let page_width_preferences =
|
||||
serde_json::to_value(&effective.page_width_preferences).unwrap_or_else(|_| json!({}));
|
||||
let ai_preferences =
|
||||
serde_json::to_value(&effective.ai_preferences).unwrap_or_else(|_| json!({}));
|
||||
let sources = effective
|
||||
.sources
|
||||
.into_iter()
|
||||
@@ -354,6 +438,8 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
|
||||
"documentId": effective.scope.document_id,
|
||||
},
|
||||
"pageOptions": page_options,
|
||||
"pageWidthPreferences": page_width_preferences,
|
||||
"aiPreferences": ai_preferences,
|
||||
"sources": Value::Object(sources),
|
||||
}
|
||||
})
|
||||
@@ -474,6 +560,127 @@ fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value)
|
||||
false
|
||||
}
|
||||
|
||||
fn page_width_content_type_for_key(key: &str) -> Option<&'static str> {
|
||||
let content_type = key.trim().strip_prefix("pageWidth.")?;
|
||||
PAGE_WIDTH_CONTENT_TYPES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| *candidate == content_type)
|
||||
}
|
||||
|
||||
fn default_page_width_preferences() -> BTreeMap<String, EffectivePageWidthPreference> {
|
||||
PAGE_WIDTH_CONTENT_TYPES
|
||||
.into_iter()
|
||||
.map(|content_type| {
|
||||
let mode = match content_type {
|
||||
"default" => "comfortable",
|
||||
"markdown" => "readable",
|
||||
"word" | "pdf" | "ppt" => "wide",
|
||||
"excel" | "mindmap" => "full",
|
||||
_ => "comfortable",
|
||||
};
|
||||
(
|
||||
content_type.to_string(),
|
||||
EffectivePageWidthPreference {
|
||||
mode: mode.to_string(),
|
||||
custom: None,
|
||||
resolved_mode: mode.to_string(),
|
||||
resolved_custom: None,
|
||||
css_max_width: page_width_css_max_width(mode).to_string(),
|
||||
source: "system".to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_page_width_preference(
|
||||
content_type: &str,
|
||||
value: &Value,
|
||||
) -> Option<PageWidthPreferenceValue> {
|
||||
let mode = value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("mode"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|mode| !mode.is_empty())?;
|
||||
let mode = if page_width_mode_is_supported(mode) {
|
||||
mode
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let mode = if content_type == "default" && mode == "inherit" {
|
||||
"comfortable"
|
||||
} else {
|
||||
mode
|
||||
};
|
||||
let custom = value
|
||||
.as_object()
|
||||
.and_then(|object| object.get("custom"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|custom| !custom.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
Some(PageWidthPreferenceValue {
|
||||
mode: mode.to_string(),
|
||||
custom,
|
||||
})
|
||||
}
|
||||
|
||||
fn page_width_mode_is_supported(mode: &str) -> bool {
|
||||
matches!(
|
||||
mode,
|
||||
"inherit" | "readable" | "comfortable" | "wide" | "full" | "custom"
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_page_width_preferences(
|
||||
preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
|
||||
) {
|
||||
let default_mode = preferences
|
||||
.get("default")
|
||||
.map(|preference| preference.mode.as_str())
|
||||
.filter(|mode| *mode != "inherit")
|
||||
.unwrap_or("comfortable")
|
||||
.to_string();
|
||||
let default_custom = preferences
|
||||
.get("default")
|
||||
.and_then(|preference| preference.custom.clone());
|
||||
|
||||
for content_type in PAGE_WIDTH_CONTENT_TYPES {
|
||||
let Some(preference) = preferences.get_mut(content_type) else {
|
||||
continue;
|
||||
};
|
||||
let resolved_mode = if content_type != "default" && preference.mode == "inherit" {
|
||||
default_mode.clone()
|
||||
} else if preference.mode == "inherit" {
|
||||
"comfortable".to_string()
|
||||
} else {
|
||||
preference.mode.clone()
|
||||
};
|
||||
let resolved_custom = if content_type != "default" && preference.mode == "inherit" {
|
||||
default_custom.clone()
|
||||
} else {
|
||||
preference.custom.clone()
|
||||
};
|
||||
preference.resolved_mode = resolved_mode.clone();
|
||||
preference.resolved_custom = resolved_custom;
|
||||
preference.css_max_width = page_width_css_max_width(&resolved_mode).to_string();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page_width_css_max_width(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"readable" => "760px",
|
||||
"comfortable" => "980px",
|
||||
"wide" => "1180px",
|
||||
"full" => "none",
|
||||
"custom" => "980px",
|
||||
_ => "980px",
|
||||
}
|
||||
}
|
||||
|
||||
trait EmptyStringExt {
|
||||
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||
StreamSnapshotQuery, build_stream_push_delta_hint, load_stream_snapshot,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
pub async fn socket(
|
||||
|
||||
@@ -27,6 +27,9 @@ pub fn HomePage(
|
||||
/// 当前选中的页面标题(可选)
|
||||
#[prop(optional)]
|
||||
active_page_title: Option<String>,
|
||||
/// 导航页主体 HTML(可选)
|
||||
#[prop(optional)]
|
||||
navigation_html: String,
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
@@ -37,6 +40,9 @@ pub fn HomePage(
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "个人".to_string());
|
||||
let has_active_page = !active_page_id.trim().is_empty();
|
||||
let navigation_html = Some(navigation_html)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let workspace_id = workspace_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
@@ -63,6 +69,57 @@ pub fn HomePage(
|
||||
</section>
|
||||
</main>
|
||||
}.into_any()
|
||||
} else if let Some(navigation_html) = navigation_html.clone() {
|
||||
view! {
|
||||
<main
|
||||
class="document-workspace mnote-navigation-page-workspace"
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane="false"
|
||||
data-workspace-id={workspace_id.clone().unwrap_or_default()}
|
||||
>
|
||||
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary">
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-main-tab is-active"
|
||||
data-mnote-main-tab="page"
|
||||
data-mnote-tab-kind="page"
|
||||
data-pane-role="primary"
|
||||
data-workspace-id={workspace_id.clone().unwrap_or_default()}
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
|
||||
<span class="mnote-main-tab-title">"主页"</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-main-tab-panels">
|
||||
<div
|
||||
data-mnote-page-tab-panel="true"
|
||||
data-pane-role="primary"
|
||||
>
|
||||
<div
|
||||
class="mnote-navigation-page-host"
|
||||
data-mnote-navigation-page-placeholder="true"
|
||||
data-pane-role="primary"
|
||||
>
|
||||
<div inner_html={navigation_html}></div>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
class="mnote-resource-tab-host"
|
||||
data-mnote-resource-tab-host="true"
|
||||
data-pane-role="primary"
|
||||
data-testid="mnote-resource-tab-host"
|
||||
hidden=true
|
||||
>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
}.into_any()
|
||||
} else {
|
||||
let empty_primary_model = DocumentPaneViewModel {
|
||||
pane_role: "primary",
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn PageLayout(
|
||||
let tree_live_bootstrap = serde_json::json!({
|
||||
"schema": "mnote.tree_live_bootstrap.v1",
|
||||
"disabled": !enable_tree_live,
|
||||
"transport": if enable_tree_live { "convex-command-log-ws" } else { "disabled" },
|
||||
"transport": if enable_tree_live { "tree-live-ws" } else { "disabled" },
|
||||
"workspaceId": null,
|
||||
"rootIds": [],
|
||||
"initialRevision": null,
|
||||
@@ -356,15 +356,18 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}"));
|
||||
|
||||
let render_page_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
|
||||
let render_page_rows =
|
||||
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows");
|
||||
assert!(render_page_rows.contains("sidebarTreeViewStateFor('pagetree'"));
|
||||
assert!(render_page_rows.contains("expandedIds"));
|
||||
assert!(
|
||||
!render_page_rows.contains("var expanded = expandable && item.expandedByDefault !== false"),
|
||||
!render_page_rows
|
||||
.contains("var expanded = expandable && item.expandedByDefault !== false"),
|
||||
"PageTree 不能只依赖 projection expandedByDefault 决定展开"
|
||||
);
|
||||
|
||||
let render_file_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
|
||||
let render_file_rows =
|
||||
js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows");
|
||||
assert!(render_file_rows.contains("sidebarTreeViewStateFor('filetree'"));
|
||||
assert!(render_file_rows.contains("expandedRelativePaths"));
|
||||
}
|
||||
@@ -387,6 +390,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openNavigationPageForFolder"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/navigation/recent"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("authRedirectUrl"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(authRedirectUrl())"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.assign(url)"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId"));
|
||||
@@ -414,6 +422,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_folder_click_toggles_instead_of_navigation_page() {
|
||||
let folder_branch = SIDEBAR_TREE_RUNTIME_JS
|
||||
.split("if (fileAction === 'open' && rowKind === 'folder') {")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("return;").next())
|
||||
.expect("filetree folder open branch exists");
|
||||
assert!(folder_branch.contains("toggleChildren(fileRow"));
|
||||
assert!(!folder_branch.contains("openNavigationPageForFolder"));
|
||||
assert!(!folder_branch.contains("window.location.assign"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
|
||||
assert!(
|
||||
@@ -655,6 +675,10 @@ mod tests {
|
||||
injection.contains("closestAction"),
|
||||
"attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError"
|
||||
);
|
||||
assert!(
|
||||
injection.contains("hydrateEditorAttachmentMeta"),
|
||||
"attachment open runtime 需要显式注入 hydrateEditorAttachmentMeta,避免上传附件增强时 ReferenceError"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("closestAction = typeof injectedClosestAction === 'function'")
|
||||
@@ -1412,9 +1436,16 @@ mod tests {
|
||||
#[test]
|
||||
fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS
|
||||
.contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"),
|
||||
"Delete/Backspace 快捷键应至少评估 workspace.readonly 上下文"
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains(
|
||||
"evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')"
|
||||
),
|
||||
"Delete/Backspace 快捷键应评估 workspace.readonly 与 editor.dirty 上下文"
|
||||
);
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains(
|
||||
"evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')"
|
||||
),
|
||||
"F2 快捷键应与菜单重命名共用 command context"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1435,6 +1466,12 @@ mod tests {
|
||||
assert!(table_loop.contains("/api/tables/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_runtime_does_not_keep_retired_table_engine_branches() {
|
||||
let retired_table_engine = ["lucky", "sheet"].concat();
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains(&retired_table_engine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_asset_open_uses_owner_document_id() {
|
||||
assert!(
|
||||
@@ -1520,6 +1557,18 @@ mod tests {
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() {
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openPdfEditorAttachment"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("function buildPdfPreviewOpenUrl"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("buildPdfPreviewOpenUrl"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
.contains("var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;"));
|
||||
let open_pdf_body = js_function_body(
|
||||
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS,
|
||||
"openPdfEditorAttachment",
|
||||
);
|
||||
assert!(open_pdf_body.contains("buildPdfPreviewOpenUrl(resolved.url, title)"));
|
||||
assert!(
|
||||
!open_pdf_body.contains("window.open(resolved.url, '_blank', 'noopener,noreferrer');")
|
||||
);
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openCodeEditorAttachment"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
@@ -1553,8 +1602,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree-live-sse"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree-live-ws"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("new WebSocket(url.toString())"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
|
||||
@@ -1651,8 +1651,8 @@ body {
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"],
|
||||
.sidebar-tree .tree-row[data-selected="true"] {
|
||||
background: rgba(255, 71, 71, 0.1);
|
||||
color: #E0525B;
|
||||
background: rgba(55, 53, 47, 0.08);
|
||||
color: #2D2E2E;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-drop-feedback="true"],
|
||||
@@ -1676,26 +1676,30 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle:hover {
|
||||
background: rgba(27, 28, 28, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.sidebar-tree .tree-toggle::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid currentColor;
|
||||
display: block;
|
||||
fill: #878787;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-toggle[aria-expanded="true"] .tree-toggle-icon {
|
||||
.sidebar-tree .tree-row[aria-expanded="true"] > .tree-toggle::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-toggle-icon {
|
||||
fill: #CF5659;
|
||||
.sidebar-tree .tree-row[data-active="true"] .tree-toggle {
|
||||
color: #8F8A84;
|
||||
}
|
||||
|
||||
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
|
||||
@@ -1703,58 +1707,137 @@ body {
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge {
|
||||
--mnote-filetree-icon-file: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 2h7l3 3v9H3zM10 2v3h3' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-folder: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 4h5l1 2h8v8H1z' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-markdown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 2h5l3 3v9H4zM9 2v3h3M6 8h4M6 10h4M6 12h2' fill='none' stroke='black' stroke-width='1.35'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-table: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 2.5h13v11h-13zM1.5 6h13M1.5 9.5h13M6 2.5v11m4-11v11' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 3h13v10h-13zM3 11l3-3 2 2 2-2 3 3M5 6h.1' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-mindmap: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 4h3v3H3zM10 2h3v3h-3zM10 11h3v3h-3zM6 6l4-2M6 6l4 6' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-pdf: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 2h7l3 3v9H3zM4.5 9h7M4.5 11h7M10 2v3h3' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-word: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 2h10v12H3zM5 5h6M5 8h6M5 11h4' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-ppt: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2 3h12v8H2zM5 14h6M8 11v3M5 6h6M5 8h4' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-sheet: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2 2h12v12H2zM2 6h12M2 10h12M6 2v12m4-12v12' fill='none' stroke='black' stroke-width='1.4'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-code: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 5 2 8l3 3V9H4V7h1zm6 0v2h1v2h-1v2l3-3zm-2-2H7L5 13h2z'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-web: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M2 3h12v10H2zm1 3v6h10V6zm0-2v1h10V4z'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-config: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 4h10v2H3zm0 3h10v2H3zm0 3h10v2H3zM5 3h2v4H5zm4 3h2v4H9z'/%3E%3C/svg%3E");
|
||||
--mnote-filetree-icon-office: var(--mnote-filetree-icon-word);
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-file);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 20px;
|
||||
color: #111111;
|
||||
font-size: 15px;
|
||||
color: #9A958F;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge::before {
|
||||
content: "□";
|
||||
font-family: var(--wolai-font-sans);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
background: currentColor;
|
||||
-webkit-mask: var(--mnote-filetree-icon-mask) center / contain no-repeat;
|
||||
mask: var(--mnote-filetree-icon-mask) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="page"]::before {
|
||||
content: "";
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-file);
|
||||
}
|
||||
|
||||
.sidebar-tree[data-tree-shell-mode="filetree"] .tree-kind-badge[data-kind="page"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="document"]::before {
|
||||
content: "◇";
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-file);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="index"]::before {
|
||||
content: "▤";
|
||||
color: var(--atelier-secondary);
|
||||
.sidebar-tree .tree-kind-badge[data-kind="index"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="markdown"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-markdown);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="index"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="markdown"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="code"] {
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="code"]::before { --mnote-filetree-icon-mask: var(--mnote-filetree-icon-code); }
|
||||
.sidebar-tree .tree-kind-badge[data-kind="web"]::before { --mnote-filetree-icon-mask: var(--mnote-filetree-icon-web); }
|
||||
.sidebar-tree .tree-kind-badge[data-kind="config"]::before { --mnote-filetree-icon-mask: var(--mnote-filetree-icon-config); }
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="folder"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="asset_folder"]::before {
|
||||
content: "▱";
|
||||
color: #D08A1F;
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-folder);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="folder"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="asset_folder"] {
|
||||
color: #B8751A;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="image"] {
|
||||
color: #5B6CF0;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="image"]::before {
|
||||
content: "▧";
|
||||
color: #5B7CFA;
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-image);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="mindmap"] {
|
||||
color: #4F8F68;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="mindmap"]::before {
|
||||
content: "⌘";
|
||||
color: #31AA4D;
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-mindmap);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="table"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="luckysheet"]::before {
|
||||
content: "▦";
|
||||
color: #2367F6;
|
||||
.sidebar-tree .tree-kind-badge[data-kind="pdf"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-pdf);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="pdf"] {
|
||||
color: #E74C3C;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="office"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="onlyoffice"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="word"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-word);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="ppt"]::before,
|
||||
.sidebar-tree .tree-kind-badge[data-kind="presentation"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-ppt);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="sheet"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-sheet);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="office"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="onlyoffice"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="web"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="word"] {
|
||||
color: #4F7DF3;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="ppt"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="presentation"] {
|
||||
color: #D94841;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="sheet"] {
|
||||
color: #2F9E44;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="table"]::before {
|
||||
--mnote-filetree-icon-mask: var(--mnote-filetree-icon-table);
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-kind-badge[data-kind="table"],
|
||||
.sidebar-tree .tree-kind-badge[data-kind="config"] {
|
||||
color: #5572A2;
|
||||
}
|
||||
|
||||
.sidebar-tree .tree-link {
|
||||
@@ -2357,6 +2440,93 @@ body {
|
||||
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
|
||||
}
|
||||
|
||||
.mnote-navigation-page-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--wolai-bg-main);
|
||||
}
|
||||
|
||||
.mnote-navigation-page {
|
||||
width: min(960px, calc(100% - 64px));
|
||||
margin: 0 auto;
|
||||
padding: 56px 0 96px;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-navigation-page__header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__notice {
|
||||
margin: 0 0 20px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(210, 180, 90, 0.42);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 247, 221, 0.72);
|
||||
color: var(--wolai-text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__notice span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--wolai-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__section h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--wolai-text-secondary);
|
||||
}
|
||||
|
||||
.mnote-navigation-page__section p {
|
||||
margin: 0;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
color: var(--wolai-text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mnote-navigation-page__item:hover {
|
||||
background: var(--wolai-bg-sidebar);
|
||||
}
|
||||
|
||||
.document-main-editor-group {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -4236,6 +4406,20 @@ button.wolai-page-ai-message-text {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-agent-selector,
|
||||
.wolai-page-ai-context-refs,
|
||||
.wolai-page-ai-allowed-roots {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px 0;
|
||||
}
|
||||
|
||||
.wolai-page-ai-allowed-roots {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-composer:focus-within {
|
||||
border-color: rgba(27, 28, 28, 0.32);
|
||||
}
|
||||
@@ -4515,11 +4699,62 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains(".mnote-tree-context-menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_icons_use_css_masks_instead_of_text_glyphs() {
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-file"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-folder"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-markdown"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-table"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-image"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mindmap"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-pdf"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-office"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-word"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-ppt"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-sheet"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-code"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-web"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-config"));
|
||||
assert!(MNOTE_CSS.contains("xmlns='http://www.w3.org/2000/svg'"));
|
||||
assert!(
|
||||
MNOTE_CSS.contains("mask: var(--mnote-filetree-icon-mask) center / contain no-repeat")
|
||||
);
|
||||
assert!(
|
||||
MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-folder);")
|
||||
);
|
||||
assert!(
|
||||
MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-markdown);")
|
||||
);
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-table);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-image);"));
|
||||
assert!(
|
||||
MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-mindmap);")
|
||||
);
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-pdf);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-word);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-ppt);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-sheet);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-code);"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-web);"));
|
||||
assert!(
|
||||
MNOTE_CSS.contains("--mnote-filetree-icon-mask: var(--mnote-filetree-icon-config);")
|
||||
);
|
||||
let retired_table_engine = ["lucky", "sheet"].concat();
|
||||
assert!(!MNOTE_CSS.contains(&retired_table_engine));
|
||||
assert!(MNOTE_CSS.contains("color: #4F7DF3;"));
|
||||
assert!(MNOTE_CSS.contains("color: #D94841;"));
|
||||
assert!(MNOTE_CSS.contains("color: #2F9E44;"));
|
||||
assert!(MNOTE_CSS.contains("color: #E74C3C;"));
|
||||
assert!(!MNOTE_CSS.contains(
|
||||
"font-family: var(--wolai-font-sans);\n font-size: 18px;\n line-height: 1;\n}"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 110000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单、文件树图标、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 114000);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "storage-convex-bridge"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
core-domain = { path = "../core-domain" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
event-log = { path = "../event-log" }
|
||||
@@ -1,18 +0,0 @@
|
||||
# storage-convex-bridge
|
||||
|
||||
阶段 1 的 Convex 桥接 crate。
|
||||
|
||||
当前职责:
|
||||
- 把 Rust 协议对象映射为 Convex 读写请求
|
||||
- 透传 request / actor / workspace / auth 上下文
|
||||
- 生成最小 CommandLog / DomainEvent 骨架
|
||||
|
||||
当前明确不做:
|
||||
- 不复制主事实层
|
||||
- 不维护第二数据库
|
||||
- 不隐藏 tenant / auth / request context
|
||||
- 不承载复杂业务裁决
|
||||
|
||||
相关设计文档:
|
||||
- `../../design/phase1-f-bridge-responsibilities-v0.md`
|
||||
- `../../design/phase1-f-write-read-flow-v0.md`
|
||||
@@ -1,18 +0,0 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeContext {
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub actor_type: String,
|
||||
pub actor_id: String,
|
||||
pub session_id: Option<String>,
|
||||
pub tenant_id: Option<String>,
|
||||
pub auth_token: Option<String>,
|
||||
pub source_channel: String,
|
||||
pub source_client: String,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub validate_only: bool,
|
||||
pub dry_run: bool,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
use crate::context::BridgeContext;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConvexMutationRequest {
|
||||
pub function_name: String,
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub idempotency_key: Option<String>,
|
||||
pub actor_id: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConvexQueryRequest {
|
||||
pub function_name: String,
|
||||
pub deployment_id: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
pub actor_id: String,
|
||||
pub payload_json: String,
|
||||
}
|
||||
|
||||
pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
|
||||
match command_name {
|
||||
"create_workspace" => "workspaces:create",
|
||||
"create_page" => "pages:create",
|
||||
"kernel.node.create" => "kernel:createNode",
|
||||
"kernel.node.update" => "kernel:updateNode",
|
||||
"kernel.subtree.move" => "kernel:moveSubtree",
|
||||
"kernel.edge.attach" => "kernel:attachEdge",
|
||||
"kernel.edge.detach" => "kernel:detachEdge",
|
||||
"tree.node.create" => "documents:createWithParentReference",
|
||||
"tree.node.rename" => "documents:updateTitle",
|
||||
"tree.node.archive" => "documents:softDelete",
|
||||
"tree.node.restore" => "documents:restore",
|
||||
"tree.node.purge" => "documents:purge",
|
||||
"tree.trash.emptyWorkspace" => "documents:emptyTrashByWorkspace",
|
||||
"tree.subtree.move" => "documents:move",
|
||||
"tree.subtree.copy" => "documents:copyTree",
|
||||
"tree.resource.copy" => "mediaAssets:batchCopy",
|
||||
"tree.resource.move" => "mediaAssets:batchMove",
|
||||
"tree.resource.upload" => "mediaAssets:createWithStorage",
|
||||
"tree.resource.archive" => "mediaAssets:patchById",
|
||||
"tree.resource.restore" => "mediaAssets:patchById",
|
||||
"tree.resource.purge" => "mediaAssets:purgeById",
|
||||
"tree.resource.rename" => "mediaAssets:patchById",
|
||||
"tree.filetree.drop.preflight" => "tree:fileTreeDropPreflight",
|
||||
"tree.filetree.delete.preflight" => "tree:fileTreeDeletePreflight",
|
||||
"tree.filetree.paste.preflight" => "tree:fileTreePastePreflight",
|
||||
"tree.filetree.upload-target.preflight" => "tree:fileTreeUploadTargetPreflight",
|
||||
"tree.node.embed" => "documents:updateContent",
|
||||
"documents.create" => "documents:createWithParentReference",
|
||||
"documents.move" => "documents:move",
|
||||
"documents.delete" => "documents:softDelete",
|
||||
"documents.restore" => "documents:restore",
|
||||
"documents.duplicate" => "documents:duplicateWithMindmaps",
|
||||
"documents.copy_tree" => "documents:copyTree",
|
||||
"documents.template" => "documents:setTemplate",
|
||||
"documents.emptyTrashByWorkspace" => "documents:emptyTrashByWorkspace",
|
||||
"documents.purge" => "documents:purge",
|
||||
"documents.embed" => "documents:updateContent",
|
||||
"blocks.patch" => "documents:updateContent",
|
||||
"documents.save" => "documents:updateContent",
|
||||
"page.body.save" => "documents:updateContent",
|
||||
"documents.title.update" => "documents:updateTitle",
|
||||
"page.head.updateTitle" => "documents:updateTitle",
|
||||
"documents.stats.update" => "documents:updateStats",
|
||||
"documents.options.update" => "documents:updateOptions",
|
||||
"page.layout.updateOptions" => "documents:updateOptions",
|
||||
"mindmaps.put" => "mindmaps:put",
|
||||
"mindmap.command.apply" => "mindmaps:applyCommand",
|
||||
"mindmaps.delete" => "mindmaps:softDelete",
|
||||
"mindmaps.restore" => "mindmaps:restore",
|
||||
"mindmaps.purge" => "mindmaps:purge",
|
||||
"mindmaps.emptyTrashByWorkspace" => "mindmaps:emptyTrashByWorkspace",
|
||||
"insert_block" => "blocks:insert",
|
||||
"update_block" => "blocks:update",
|
||||
"move_block" => "blocks:move",
|
||||
"delete_block" => "blocks:delete",
|
||||
"blocks.move" => "blocks:move",
|
||||
"blocks.embed" => "blocks:insert",
|
||||
"media.assets.replace_storage" => "mediaAssets:replaceStorageFromUpload",
|
||||
_ => "commands:unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
|
||||
match query_name {
|
||||
"kernel.node.get" => "kernel:getNode",
|
||||
"kernel.subtree.get" => "kernel:getSubtree",
|
||||
"kernel.children.list" => "kernel:listChildren",
|
||||
"kernel.edges.list" => "kernel:listEdges",
|
||||
"kernel.graph.traverse" => "kernel:traverseGraph",
|
||||
"kernel.project_view" => "kernel:projectView",
|
||||
"bridge.request.get" => "bridgeLogs:listByRequest",
|
||||
"bridge.trace.get" => "bridgeLogs:listByTrace",
|
||||
"bridge.command.get" => "bridgeLogs:listByCommand",
|
||||
"bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview",
|
||||
"documents.meta.get" => "documents:getMeta",
|
||||
"documents.content.get" => "documents:getContent",
|
||||
// page aggregate 主读链由 Rust /api/page-aggregate/:id 先取 meta/content 后在 bridge-runtime 组装。
|
||||
// Convex 没有 documents:getPageAggregate,避免旧映射误触发不存在的生产函数。
|
||||
"page.aggregate.get" => "queries:unknown",
|
||||
"mindmaps.get" => "mindmaps:get",
|
||||
"mindmap.projection.get" => "mindmaps:getProjection",
|
||||
"mindmap.editor_scene.get" => "mindmaps:getEditorScene",
|
||||
"blocks.get" => "blocks:getById",
|
||||
"sidebar.dataset.list" => "sidebar:datasetList",
|
||||
"search.documents" => "search:documents",
|
||||
"search.documents.query" => "search:documents",
|
||||
"search.recent" => "search:recent",
|
||||
"get_page" => "pages:get",
|
||||
"list_page_blocks" => "blocks:list_by_page",
|
||||
"search_pages" => "search:pages",
|
||||
"search_blocks" => "search:blocks",
|
||||
_ => "queries:unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_opt(value: &Option<String>) -> String {
|
||||
match value {
|
||||
Some(v) => format!("\"{}\"", v),
|
||||
None => "null".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn payload_json_for_command(context: &BridgeContext, command_name: &str) -> String {
|
||||
format!(
|
||||
"{{\"kind\":\"command\",\"name\":\"{}\",\"request_id\":\"{}\",\"trace_id\":\"{}\",\"deployment_id\":{},\"project_id\":{},\"workspace_id\":{},\"tenant_id\":{},\"idempotency_key\":{},\"actor\":{{\"type\":\"{}\",\"id\":\"{}\",\"session_id\":{}}},\"source\":{{\"channel\":\"{}\",\"client\":\"{}\"}}}}",
|
||||
command_name,
|
||||
context.request_id,
|
||||
context.trace_id,
|
||||
json_opt(&context.deployment_id),
|
||||
json_opt(&context.project_id),
|
||||
json_opt(&context.workspace_id),
|
||||
json_opt(&context.tenant_id),
|
||||
json_opt(&context.idempotency_key),
|
||||
context.actor_type,
|
||||
context.actor_id,
|
||||
json_opt(&context.session_id),
|
||||
context.source_channel,
|
||||
context.source_client,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn payload_json_for_query(context: &BridgeContext, query_name: &str) -> String {
|
||||
format!(
|
||||
"{{\"kind\":\"query\",\"name\":\"{}\",\"request_id\":\"{}\",\"trace_id\":\"{}\",\"deployment_id\":{},\"project_id\":{},\"workspace_id\":{},\"tenant_id\":{},\"actor_id\":\"{}\",\"source\":{{\"channel\":\"{}\",\"client\":\"{}\"}}}}",
|
||||
query_name,
|
||||
context.request_id,
|
||||
context.trace_id,
|
||||
json_opt(&context.deployment_id),
|
||||
json_opt(&context.project_id),
|
||||
json_opt(&context.workspace_id),
|
||||
json_opt(&context.tenant_id),
|
||||
context.actor_id,
|
||||
context.source_channel,
|
||||
context.source_client,
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use crate::context::BridgeContext;
|
||||
use crate::mapping::{map_query_name_to_convex, payload_json_for_query, ConvexQueryRequest};
|
||||
use crate::types::BridgeResult;
|
||||
use crate::validation::validate_query_envelope;
|
||||
use core_protocol::QueryEnvelope;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueryReadResult {
|
||||
pub workspace_id: Option<String>,
|
||||
pub request_id: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
pub fn build_query_request<T>(
|
||||
context: &BridgeContext,
|
||||
query: &QueryEnvelope<T>,
|
||||
) -> BridgeResult<ConvexQueryRequest> {
|
||||
validate_query_envelope(context, query)?;
|
||||
Ok(ConvexQueryRequest {
|
||||
function_name: map_query_name_to_convex(&query.name).to_string(),
|
||||
deployment_id: context.deployment_id.clone(),
|
||||
project_id: context.project_id.clone(),
|
||||
workspace_id: context.workspace_id.clone(),
|
||||
request_id: context.request_id.clone(),
|
||||
trace_id: context.trace_id.clone(),
|
||||
actor_id: context.actor_id.clone(),
|
||||
payload_json: payload_json_for_query(context, &query.name),
|
||||
})
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BridgeErrorKind {
|
||||
Validation,
|
||||
Unauthorized,
|
||||
Conflict,
|
||||
NotFound,
|
||||
Transport,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BridgeError {
|
||||
pub kind: BridgeErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl BridgeError {
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Validation,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transport(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Transport,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Unauthorized,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Conflict,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::NotFound,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rejected(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: BridgeErrorKind::Rejected,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user