feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
+2 -7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+469 -11
View File
@@ -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
View File
@@ -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"
}
}
+232
View File
@@ -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: {}
-48
View File
@@ -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"
-1
View File
@@ -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",
]
-1
View File
@@ -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"] }
+164 -9
View File
@@ -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": {
+1 -2
View File
@@ -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() {
-3
View File
@@ -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");
}
+34
View File
@@ -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,
+341 -6
View File
@@ -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();
+17 -5
View File
@@ -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,
-1
View File
@@ -6,6 +6,5 @@ license.workspace = true
authors.workspace = true
[dependencies]
core-domain = { path = "../core-domain" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+88
View File
@@ -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);
+38
View File
@@ -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());
-3
View File
@@ -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 -2
View File
@@ -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 视为正文真源入口。
+41 -784
View File
@@ -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(&current), block_id, &snapshot)?;
let args = json!({
"id": page_id,
"content": write_blocks_back(&current, 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"));
}
}
-1
View File
@@ -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(),
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
const active = entry?.tab instanceof HTMLElement
? entry.tab.getAttribute('aria-selected') === 'true'
: 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: normalizeResourceTabKind(entry),
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: 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(),
});
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);
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()
+2 -12
View File
@@ -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(
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!(
+5 -5
View File
@@ -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,
+1 -1
View File
@@ -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 -1
View File
@@ -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};
+114 -41
View File
@@ -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"]
assert!(
payload["details"]["conflict"]["currentDiskVersion"]
.as_str()
.unwrap_or("")
.starts_with("local-md:local-md:README.md:"));
assert!(payload["details"]["conflict"]["suggestedActions"]
.starts_with("local-md:local-md:README.md:")
);
assert!(
payload["details"]["conflict"]["suggestedActions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str() == Some("merge")));
.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"));
+6 -6
View File
@@ -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 {
+659 -63
View File
@@ -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,12 +558,12 @@ 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,
@@ -543,15 +571,12 @@ pub async fn root_entry(
"panes": [],
}))
.unwrap_or_else(|_| "{}".to_string());
format!(
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()
};
);
("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
assert!(
response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.contains("text/html"));
.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
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_id=new-user")));
assert!(values
.any(|value| value.contains("mnote_actor_id=new-user"))
);
assert!(
values
.iter()
.any(|value| value.contains("mnote_actor_type=user")));
.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"]
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("注册账号时请填写邮箱"));
.contains("注册账号时请填写邮箱")
);
}
#[tokio::test]
@@ -3261,9 +3851,11 @@ mod tests {
.iter()
.map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>();
assert!(values
assert!(
values
.iter()
.any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")));
.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
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthJWT=")));
assert!(!values
.any(|value| value.contains("__convexAuthJWT="))
);
assert!(
!values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=")));
.any(|value| value.contains("__convexAuthRefreshToken="))
);
assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
}
+1 -1
View File
@@ -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)]
+11 -9
View File
@@ -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"]
assert!(
payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/"));
.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
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.plan_update"));
assert!(tools
.any(|tool| tool["name"] == "mnote.doc.plan_update")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.replace"));
assert!(tools
.any(|tool| tool["name"] == "mnote.block.replace")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.insert_after"));
assert!(tools
.any(|tool| tool["name"] == "mnote.block.insert_after")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.delete"));
assert!(tools
.any(|tool| tool["name"] == "mnote.block.delete")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.block.move_after"));
assert!(tools
.any(|tool| tool["name"] == "mnote.block.move_after")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops"));
assert!(tools
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch"));
assert!(tools
.any(|tool| tool["name"] == "mnote.mindmap.fetch")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops"));
assert!(tools
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary"));
assert!(tools
.any(|tool| tool["name"] == "mnote.office.fetch_summary")
);
assert!(
tools
.iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes"));
.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"]
assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["operations"])));
assert!(markdown_edit["inputSchema"]["anyOf"]
.any(|rule| rule["required"] == json!(["operations"]))
);
assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array()
.expect("anyOf")
.iter()
.any(|rule| rule["required"] == json!(["full_content"])));
.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"]
assert!(
markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
assert!(markdown_edit["description"]
.contains("兼容")
);
assert!(
markdown_edit["description"]
.as_str()
.expect("description")
.contains("agent 原生 patch/diff"));
.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"]
assert!(
payload["result"]["markdownSummary"]
.as_str()
.unwrap_or_default()
.contains("中心主题"));
.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"]
assert!(
payload["result"]["bodySummary"]
.as_str()
.unwrap_or_default()
.contains("章节一"));
.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"]
assert!(
payload["result"]["blocks"][0]["revisionRef"]
.as_str()
.unwrap_or_default()
.starts_with("pageRev:7:block:heading_1:hash:"));
.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"]
assert!(
payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("<block id=\"heading_1\""));
.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"]
assert!(
insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str()
.unwrap_or_default()
.starts_with("ai_block_"));
.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
assert!(
inserted_ids
.iter()
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") }));
.all(|id| { id.as_str().unwrap_or_default().starts_with("ai_block_") })
);
assert_eq!(
payload["result"]["changedBlocks"]
.as_array()
+19 -13
View File
@@ -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,7 +409,8 @@ 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"]
assert!(
payload["result"]["items"]
.as_array()
.expect("items")
.iter()
@@ -419,7 +420,8 @@ mod tests {
.as_str()
.unwrap_or("")
.starts_with("local-md:")
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面"));
&& 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
assert!(
items
.iter()
.all(|item| item["parentNodeId"].as_str() == Some("local:node:docs")));
.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"]
assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value == "expand"));
.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"]
assert!(
payload["eventKinds"]
.as_array()
.expect("event kinds")
.iter()
.any(|kind| kind.as_str() == Some("Modify(Data)")));
.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"]
assert!(
body["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
.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"]
assert!(
result["fileVersion"]
.as_str()
.expect("file version")
.starts_with("local-md:local-md:README.md:"));
.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"]
assert!(
created["grant"]["shareId"]
.as_str()
.unwrap_or_default()
.starts_with("folder_"));
.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
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link")
.is_some());
.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
assert!(
state
.control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link")
.is_none());
.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"]
assert!(
payload["workspace"]["manifest"]["capabilities"]
.as_array()
.expect("capabilities")
.iter()
.any(|value| value.as_str() == Some("markdown_edit")));
.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")
assert!(
root.join(".mnote")
.join("trash")
.join("Renamed.md")
.is_file());
.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"]
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["backlinks"]
.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"]
.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"]
.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"]
.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"]
.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")
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
.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"]
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
&& 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?"))));
.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"]
assert!(
office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
&& 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
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md"));
.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
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.any(|document| document.path == "docs/child.md")
);
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md"));
.any(|document| document.path == "README.md")
);
let _ = fs::remove_dir_all(&root);
}
+12 -12
View File
@@ -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",
+20 -16
View File
@@ -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(&params);
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;
+347 -5
View File
@@ -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));
}
}
+25 -19
View File
@@ -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"]
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("已读取第一段:第一段"));
assert!(payload["message"]
.contains("已读取第一段:第一段")
);
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("测试123"));
.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")
assert!(
root.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists());
.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
assert!(
!root
.join(".mnote")
.join("trash")
.join("map.mindmap.json")
.exists());
.exists()
);
let _ = std::fs::remove_dir_all(&root);
}
+31 -20
View File
@@ -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"]
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.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")
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
assert!(payload["recent"]
.exists()
);
assert!(
payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
.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"]
assert!(
backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
let tags_response = app()
.oneshot(
+5 -5
View File
@@ -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),
+6 -6
View File
@@ -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;
+260 -73
View File
@@ -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")
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
.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")
assert!(
root.join("docs")
.join("重命名页面 2")
.join("重命名页面 2.md")
.exists());
.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")
assert!(
root.join(".mnote")
.join("trash")
.join("重命名页面")
.join("重命名页面.md")
.exists());
.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")
assert!(
root.join("docs")
.join("重命名页面")
.join("重命名页面.md")
.exists());
.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"]
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("root"));
.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
assert!(
create_wire
.source
.capabilities
.iter()
.any(|capability| capability == "execute-command"));
.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,10 +210,7 @@ fn normalize_state(
scope: &TreeViewStateScope,
value: Value,
) -> Result<Value, WebError> {
let mut object = value
.as_object()
.cloned()
.ok_or_else(|| {
let mut object = value.as_object().cloned().ok_or_else(|| {
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
.with_context(context)
})?;
@@ -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
json!(
object
.get("scrollTop")
.and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(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 -2
View File
@@ -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",
+58 -9
View File
@@ -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"));
+267 -32
View File
@@ -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