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 exit 1
} }
if [ -d "$ROOT_DIR/wolai-frontend" ]; then if [ -f "$ROOT_DIR/package.json" ] && ! command -v node >/dev/null; then
if ! command -v node >/dev/null; then echo "WARN: 缺少 node,脚本任务可能无法执行"
echo "WARN: 缺少 node,前端相关任务可能无法执行"
fi
if ! command -v pnpm >/dev/null; then
echo "WARN: 缺少 pnpm,前端相关任务可能无法执行"
fi
fi fi
echo "Harness 环境检查完成" 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", "name": "mnote-desktop",
"version": "0.1.0", "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": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"playwright": "^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": { "node_modules/@playwright/test": {
"version": "1.56.1", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.56.1" "playwright": "1.57.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -28,6 +296,37 @@
"node": ">=18" "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": { "node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "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": "^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": { "node_modules/playwright": {
"version": "1.56.1", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.56.1" "playwright-core": "1.57.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -63,9 +425,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.56.1", "version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -74,6 +436,102 @@
"engines": { "engines": {
"node": ">=18" "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", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"desktop": "node scripts/desktop-prod.js",
"desktop:hot": "node scripts/desktop-hot.js", "desktop:hot": "node scripts/desktop-hot.js",
"dev:hot": "node scripts/dev-hot.js", "dev:hot": "node scripts/dev-hot.js"
"check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"playwright": "^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: 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: devDependencies:
'@playwright/test': '@playwright/test':
specifier: ^1.56.1 specifier: ^1.56.1
@@ -17,16 +27,119 @@ importers:
packages: 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': '@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true 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: fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] 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: playwright-core@1.57.0:
resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -37,15 +150,112 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true 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: 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': '@playwright/test@1.57.0':
dependencies: dependencies:
playwright: 1.57.0 playwright: 1.57.0
core-util-is@1.0.3: {}
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
fsevents@2.3.2: fsevents@2.3.2:
optional: true 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-core@1.57.0: {}
playwright@1.57.0: playwright@1.57.0:
@@ -53,3 +263,25 @@ snapshots:
playwright-core: 1.57.0 playwright-core: 1.57.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 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", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"storage-convex-bridge",
"urlencoding", "urlencoding",
] ]
@@ -486,7 +485,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile",
"tracing", "tracing",
"uuid", "uuid",
] ]
@@ -532,7 +530,6 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
name = "core-protocol" name = "core-protocol"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"core-domain",
"serde", "serde",
"serde_json", "serde_json",
] ]
@@ -1543,12 +1540,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.2" version = "0.8.2"
@@ -1624,15 +1615,12 @@ dependencies = [
name = "mnote-cli" name = "mnote-cli"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"bridge-runtime", "bridge-runtime",
"clap", "clap",
"core-protocol", "core-protocol",
"mnote-editor-core", "mnote-editor-core",
"reqwest",
"serde", "serde",
"serde_json", "serde_json",
"storage-convex-bridge",
] ]
[[package]] [[package]]
@@ -1662,7 +1650,6 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"storage-convex-bridge",
"time", "time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -2279,19 +2266,6 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.38" version = "0.23.38"
@@ -2615,15 +2589,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storage-convex-bridge"
version = "0.1.0"
dependencies = [
"core-domain",
"core-protocol",
"event-log",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -2711,19 +2676,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
-1
View File
@@ -9,7 +9,6 @@ members = [
"crates/mnote-editor-core", "crates/mnote-editor-core",
"crates/mnote-cli", "crates/mnote-cli",
"crates/mnote-web", "crates/mnote-web",
"crates/storage-convex-bridge",
"crates/index-fts", "crates/index-fts",
"crates/tree-shell-runtime-wasm", "crates/tree-shell-runtime-wasm",
] ]
-1
View File
@@ -11,7 +11,6 @@ core-domain = { path = "../core-domain" }
core-protocol = { path = "../core-protocol" } core-protocol = { path = "../core-protocol" }
event-log = { path = "../event-log" } event-log = { path = "../event-log" }
index-fts = { path = "../index-fts" } index-fts = { path = "../index-fts" }
storage-convex-bridge = { path = "../storage-convex-bridge" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } 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::collections::{BTreeMap, HashMap, VecDeque};
use std::env; use std::env;
use std::sync::atomic::{AtomicU64, Ordering}; 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 TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
static MINDMAP_UID_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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")] #[serde(rename_all = "camelCase", tag = "kind")]
pub enum RuntimeInput { pub enum RuntimeInput {
@@ -1573,7 +1728,7 @@ fn normalize_resource_asset_type(raw: Option<&str>) -> String {
"video" => "video".into(), "video" => "video".into(),
"audio" => "audio".into(), "audio" => "audio".into(),
"mindmap" => "mindmap".into(), "mindmap" => "mindmap".into(),
"luckysheet" => "luckysheet".into(), "table" => "table".into(),
_ => "file".into(), _ => "file".into(),
} }
} }
@@ -1633,7 +1788,7 @@ fn normalize_resource_lifecycle_kind(payload: &ResourceLifecycleCommandPayload)
.map(str::trim) .map(str::trim)
.unwrap_or("file") .unwrap_or("file")
{ {
"table" | "online-table" | "luckysheet" => "table".into(), "table" | "online-table" => "table".into(),
"mindmap" | "map" => "mindmap".into(), "mindmap" | "map" => "mindmap".into(),
_ => "file".into(), _ => "file".into(),
} }
@@ -7991,7 +8146,7 @@ fn classify_asset_kind(
if lowered_type == "mindmap" { if lowered_type == "mindmap" {
return KernelProjectionAssetKind::Mindmap; return KernelProjectionAssetKind::Mindmap;
} }
if lowered_type == "luckysheet" { if lowered_type == "table" {
return KernelProjectionAssetKind::Table; return KernelProjectionAssetKind::Table;
} }
if is_pdf_asset(file_name, mime_type) { if is_pdf_asset(file_name, mime_type) {
@@ -19981,10 +20136,10 @@ mod tests {
"id": "table_1", "id": "table_1",
"workspace_id": "ws_1", "workspace_id": "ws_1",
"document_id": "page_root", "document_id": "page_root",
"asset_type": "luckysheet", "asset_type": "table",
"file_name": "budget.luckysheet", "file_name": "budget.table",
"mime_type": "application/json", "mime_type": "application/json",
"storage_path": "budget.luckysheet" "storage_path": "budget.table"
} }
], ],
"mindmap_asset_children": { "mindmap_asset_children": {
+1 -2
View File
@@ -3,9 +3,8 @@ use std::io::{self, Read};
use bridge_runtime::{ use bridge_runtime::{
build_artifact_success_response, build_failure_response, build_success_response, build_artifact_success_response, build_failure_response, build_success_response,
execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query, 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() { fn main() {
let input = match read_stdin() { let input = match read_stdin() {
-3
View File
@@ -14,6 +14,3 @@ chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1" tracing = "0.1"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" 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", "v5-sidebar-shortcut-root-uri",
include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"), 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. /// Create the `_migrations` meta-table if it does not exist.
@@ -108,6 +112,7 @@ mod tests {
"ai_runtime_events", "ai_runtime_events",
"sidebar_shortcuts", "sidebar_shortcuts",
"user_ui_preferences", "user_ui_preferences",
"user_navigation_recent",
] { ] {
assert!(table_exists(&conn, table), "table {table} should exist"); assert!(table_exists(&conn, table), "table {table} should exist");
} }
+34
View File
@@ -243,6 +243,40 @@ pub struct UpsertUserUiPreferenceInput {
pub value_json: String, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiPolicyRecord { pub struct AiPolicyRecord {
pub id: EntityId, pub id: EntityId,
+341 -6
View File
@@ -11,10 +11,11 @@ use crate::model::{
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
}; };
use crate::store::ControlPlaneStore; 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> { fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord { Ok(AiPolicyRecord {
id: row.get(0)?, id: row.get(0)?,
@@ -313,6 +364,33 @@ fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
title.chars().take(80).collect() 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 { impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> { fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() { if input.username.trim().is_empty() {
@@ -864,7 +942,8 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
AND status = 'active' AND status = 'active'
AND ( AND (
?2 = root_uri ?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 ?3 = root_path)
OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%') OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%')
)", )",
@@ -1382,6 +1461,138 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows) 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( fn upsert_ai_policy(
&self, &self,
input: UpsertAiPolicyInput, input: UpsertAiPolicyInput,
@@ -1985,7 +2196,8 @@ mod tests {
use crate::model::{ use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
UpsertUserUiPreferenceInput,
}; };
fn store() -> SqliteControlPlaneStore { fn store() -> SqliteControlPlaneStore {
@@ -2158,6 +2370,36 @@ mod tests {
assert_eq!(access.grant_ids.len(), 1); 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] #[test]
fn share_link_token_is_hashed_and_revocable() { fn share_link_token_is_hashed_and_revocable() {
let store = store(); let store = store();
@@ -2474,6 +2716,99 @@ mod tests {
assert!(bob_sidebar_preferences.is_empty()); 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] #[test]
fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { fn ai_policy_upsert_returns_workspace_policy_before_user_policy() {
let store = store(); let store = store();
+17 -5
View File
@@ -5,11 +5,11 @@ use crate::model::{
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UserUiPreferenceRecord, WorkspaceRecord, UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
}; };
pub trait ControlPlaneStore: Send + Sync { pub trait ControlPlaneStore: Send + Sync {
@@ -136,6 +136,18 @@ pub trait ControlPlaneStore: Send + Sync {
source_kind: Option<&str>, source_kind: Option<&str>,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>; ) -> 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( fn upsert_ai_policy(
&self, &self,
input: UpsertAiPolicyInput, input: UpsertAiPolicyInput,
-1
View File
@@ -6,6 +6,5 @@ license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
core-domain = { path = "../core-domain" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+88
View File
@@ -202,7 +202,12 @@ pub struct DeleteBlock {
/// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none") /// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none")
/// - `tree.selectionCount` — 当前选中行数 (i64) /// - `tree.selectionCount` — 当前选中行数 (i64)
/// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...) /// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...)
/// - `tree.targetResourceKind` — 当前命令目标资源类型
/// - `tree.targetRowKind` — 当前命令目标行类型
/// - `tree.targetIsFolder` — 当前命令目标是否文件夹
/// - `tree.targetIsAsset` — 当前命令目标是否资源文件
/// - `editor.dirty` — bool,当前编辑器是否有未保存修改 /// - `editor.dirty` — bool,当前编辑器是否有未保存修改
/// - `editor.dirtyState` — 当前编辑器 dirty 状态 ("clean" | "dirty" | "stale" | ...)
/// - `editor.hasSelection` — bool,编辑器是否有文本选区 /// - `editor.hasSelection` — bool,编辑器是否有文本选区
/// - `ai.canWrite` — bool,当前会话是否允许 AI 写入 /// - `ai.canWrite` — bool,当前会话是否允许 AI 写入
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -217,8 +222,18 @@ pub struct CommandContext {
pub tree_selection_count: i64, pub tree_selection_count: i64,
/// 选择资源的 kind /// 选择资源的 kind
pub tree_selection_resource_kind: Option<String>, 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 /// 编辑器是否 dirty
pub editor_dirty: bool, pub editor_dirty: bool,
/// 编辑器 dirty 状态
pub editor_dirty_state: Option<String>,
/// 编辑器是否有选区 /// 编辑器是否有选区
pub editor_has_selection: bool, pub editor_has_selection: bool,
/// AI 是否可写 /// AI 是否可写
@@ -233,7 +248,12 @@ impl CommandContext {
tree_focus_kind: None, tree_focus_kind: None,
tree_selection_count: 0, tree_selection_count: 0,
tree_selection_resource_kind: None, 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: false,
editor_dirty_state: Some("clean".to_string()),
editor_has_selection: false, editor_has_selection: false,
ai_can_write: true, ai_can_write: true,
} }
@@ -256,7 +276,21 @@ impl CommandContext {
.tree_selection_resource_kind .tree_selection_resource_kind
.as_deref() .as_deref()
.map(|s| CommandContextValue::String(s.to_string())), .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.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)), "editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)),
"ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)), "ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)),
_ => None, _ => None,
@@ -293,9 +327,44 @@ impl CommandContext {
self 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。 /// 用 builder 模式设置 editor dirty。
pub fn with_editor_dirty(mut self, dirty: bool) -> Self { pub fn with_editor_dirty(mut self, dirty: bool) -> Self {
self.editor_dirty = dirty; 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 self
} }
@@ -800,6 +869,25 @@ mod command_context_tests {
assert!(is_command_enabled(&ctx, Some(""))); 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] #[test]
fn command_context_number_comparison() { fn command_context_number_comparison() {
let ctx = CommandContext::new().with_tree_selection_count(3); let ctx = CommandContext::new().with_tree_selection_count(3);
+38
View File
@@ -94,6 +94,10 @@ pub struct PageBodyWriteRequest {
alias = "conflict_detection_key" alias = "conflict_detection_key"
)] )]
pub expected_file_version: Option<String>, 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)] #[serde(default)]
pub base_content_hash: Option<String>, pub base_content_hash: Option<String>,
#[serde(default = "default_page_body_content_format")] #[serde(default = "default_page_body_content_format")]
@@ -226,6 +230,12 @@ pub struct DocumentBuffer {
pub last_saved_at: Option<i64>, pub last_saved_at: Option<i64>,
/// 外部修改者 /// 外部修改者
pub external_actor: Option<String>, 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 { impl DocumentBuffer {
@@ -240,10 +250,26 @@ impl DocumentBuffer {
} }
pub fn mark_saved(&mut self, file_version: String, content_hash: String) { 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.file_version = Some(file_version);
self.base_content_hash = Some(content_hash); self.base_content_hash = Some(content_hash);
self.current_content_hash = None; self.current_content_hash = None;
self.dirty_state = DocBufferDirtyState::Clean; 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( self.last_saved_at = Some(
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -881,6 +907,8 @@ mod tests {
"sourceKind": "local_folder", "sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash", "expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
"writeIntentId": "intent:editor:abc",
"saveOperationId": "save:op:abc",
"baseContentHash": "sha256:base", "baseContentHash": "sha256:base",
"contentFormat": "editorBlocks", "contentFormat": "editorBlocks",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}], "content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
@@ -894,6 +922,8 @@ mod tests {
request.expected_file_version.as_deref(), request.expected_file_version.as_deref(),
Some("local-md:local-md:README.md:1:2:hash") 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.content_format, "editorBlocks");
assert_eq!(request.editor_source.as_deref(), Some("tiptap")); assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
@@ -902,6 +932,8 @@ mod tests {
value["expectedFileVersion"], value["expectedFileVersion"],
json!("local-md:local-md:README.md:1:2:hash") 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["baseContentHash"], json!("sha256:base"));
assert_eq!(value["contentFormat"], json!("editorBlocks")); assert_eq!(value["contentFormat"], json!("editorBlocks"));
assert_eq!(value["editorSource"], json!("tiptap")); assert_eq!(value["editorSource"], json!("tiptap"));
@@ -1009,11 +1041,15 @@ mod tests {
last_loaded_at: Some(1_700_000_000_000i64), last_loaded_at: Some(1_700_000_000_000i64),
last_saved_at: Some(1_700_000_000_001i64), last_saved_at: Some(1_700_000_000_001i64),
external_actor: None, 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 应可序列化"); let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
assert_eq!(value["dirtyState"], json!("clean")); assert_eq!(value["dirtyState"], json!("clean"));
assert_eq!(value["fileVersion"], json!("v1")); 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")); assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
let decoded: DocumentBuffer = let decoded: DocumentBuffer =
@@ -1046,6 +1082,8 @@ mod tests {
last_loaded_at: None, last_loaded_at: None,
last_saved_at: None, last_saved_at: None,
external_actor: None, external_actor: None,
last_write_intent_id: None,
last_save_operation_id: None,
}; };
assert!(!buf.is_dirty()); assert!(!buf.is_dirty());
-3
View File
@@ -6,12 +6,9 @@ license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
base64 = "0.22"
bridge-runtime = { path = "../bridge-runtime" } bridge-runtime = { path = "../bridge-runtime" }
clap = { version = "4.5.38", features = ["derive"] } clap = { version = "4.5.38", features = ["derive"] }
core-protocol = { path = "../core-protocol" } core-protocol = { path = "../core-protocol" }
mnote-editor-core = { path = "../mnote-editor-core" } 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 = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" }
+2 -2
View File
@@ -2,8 +2,8 @@
当前 crate 是 Phase 2 的最小 CLI 协议入口。 当前 crate 是 Phase 2 的最小 CLI 协议入口。
注意:当前 `page` / `block` / `tool run doc_*` 执行面仍是 Convex 注意: `page` / `block` / `tool run doc_*` 执行面曾连接 Convex
`documents.*` 兼容路径,只适合调试、迁移验证和 cloud workspace 兼容场景 `documents.*` 兼容路径;该执行链已退役,只保留 JSON 计划输出和历史迁移对照
local-first 普通 Markdown 正文主链应优先走本地文件引用、Page Aggregate、 local-first 普通 Markdown 正文主链应优先走本地文件引用、Page Aggregate、
Rust Web local_folder source 和 agent 原生 patch/diff,不应把本 CLI 视为正文真源入口。 Rust Web local_folder source 和 agent 原生 patch/diff,不应把本 CLI 视为正文真源入口。
+41 -784
View File
@@ -1,7 +1,7 @@
use base64::Engine;
use bridge_runtime::{ use bridge_runtime::{
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire,
RuntimeToolInvocationWire,
}; };
use clap::ValueEnum; use clap::ValueEnum;
use core_protocol::{ use core_protocol::{
@@ -16,11 +16,8 @@ use mnote_editor_core::{
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession, EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
VisibilitySnapshot, VisibilitySnapshot,
}; };
use reqwest::blocking::Client;
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::env;
use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliContext { pub struct CliContext {
@@ -1554,7 +1551,7 @@ fn execute_query(
"documents.content.get" "documents.content.get"
| "documents.meta.get" | "documents.meta.get"
| "mindmaps.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), "search.documents" => execute_search_documents(transport, context, cli_ctx),
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))), other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
} }
@@ -1574,7 +1571,7 @@ fn execute_command(
| "documents.restore" | "documents.restore"
| "documents.title.update" | "documents.title.update"
| "documents.save" | "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), "insert_block" => execute_block_insert(normalized_input, context, cli_ctx),
"blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx), "blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx),
"blocks.move" => execute_block_move(normalized_input, cli_ctx), "blocks.move" => execute_block_move(normalized_input, cli_ctx),
@@ -1649,313 +1646,35 @@ fn merge_tool_args(
} }
fn execute_search_documents( fn execute_search_documents(
transport: &CliTransportPlan, _transport: &CliTransportPlan,
context: &CliOutputContext, _context: &CliOutputContext,
cli_ctx: &CliContext, _cli_ctx: &CliContext,
) -> CliResult<Value> { ) -> CliResult<Value> {
let workspace_id = transport execute_retired_cloud_transport("search.documents")
.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))
} }
fn execute_block_insert( fn execute_block_insert(
normalized_input: &Value, _normalized_input: &Value,
context: &CliOutputContext, _context: &CliOutputContext,
cli_ctx: &CliContext, _cli_ctx: &CliContext,
) -> CliResult<Value> { ) -> CliResult<Value> {
let page_id = required_str(normalized_input, "pageId")?; execute_retired_cloud_transport("insert_block")
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),
}))
} }
fn execute_block_patch( fn execute_block_patch(
normalized_input: &Value, _normalized_input: &Value,
transport: &CliTransportPlan, _transport: &CliTransportPlan,
cli_ctx: &CliContext, _cli_ctx: &CliContext,
) -> CliResult<Value> { ) -> CliResult<Value> {
let page_id = required_str(normalized_input, "pageId")?; execute_retired_cloud_transport("blocks.patch")
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)
} }
fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> { fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
let source_document_id = required_str(normalized_input, "sourceDocumentId")?; execute_retired_cloud_transport("blocks.move")
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_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> { fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
let source_document_id = required_str(normalized_input, "sourceDocumentId")?; execute_retired_cloud_transport("blocks.embed")
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 build_tool_target( fn build_tool_target(
@@ -2007,7 +1726,7 @@ fn load_tool_data(
tool_name: &str, tool_name: &str,
args: &Value, args: &Value,
target: Option<&RuntimeTargetWire>, target: Option<&RuntimeTargetWire>,
cli_ctx: &CliContext, _cli_ctx: &CliContext,
) -> CliResult<Value> { ) -> CliResult<Value> {
match tool_name { match tool_name {
"search_web" | "slash_run" | "event_replay" | "index_rebuild" => { "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 })), "image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })),
"doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => { "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => {
let page_id = read_doc_target(args, target)?; let _ = read_doc_target(args, target)?;
let result = execute_convex_query_raw( Err(retired_cloud_transport_error(tool_name))
"documents:getContent",
json!({ "id": page_id }),
cli_ctx,
)?;
Ok(
json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }),
)
} }
"docs_read" => { "docs_read" => {
let document_id = args let document_id = args
.get("documentId") .get("documentId")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?; .ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?;
let meta = execute_convex_query_raw( let _ = document_id;
"documents:getMeta", Err(retired_cloud_transport_error(tool_name))
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),
}))
} }
"docs_search" => { "docs_search" => {
let workspace_id = args let workspace_id = args
.get("workspaceId") .get("workspaceId")
.and_then(Value::as_str) .and_then(Value::as_str)
.ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?; .ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?;
let documents = execute_convex_query_raw( let _ = workspace_id;
"documents:listSearchDataByWorkspace", Err(retired_cloud_transport_error(tool_name))
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>>()
}]
}))
} }
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => { "mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
let document_id = read_doc_target(args, target)?; let document_id = read_doc_target(args, target)?;
@@ -2129,14 +1760,8 @@ fn load_tool_data(
.and_then(Value::as_str) .and_then(Value::as_str)
.or_else(|| target.and_then(|value| value.block_id.as_deref())) .or_else(|| target.and_then(|value| value.block_id.as_deref()))
.ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?; .ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?;
let result = execute_convex_query_raw( let _ = (document_id, mindmap_id);
"mindmaps:get", Err(retired_cloud_transport_error(tool_name))
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) }),
)
} }
other => Err(CliError::validation(format!( other => Err(CliError::validation(format!(
"暂不支持 tool 数据加载: {other}" "暂不支持 tool 数据加载: {other}"
@@ -2144,183 +1769,14 @@ fn load_tool_data(
} }
} }
fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> { fn execute_retired_cloud_transport(operation: &str) -> CliResult<Value> {
execute_convex_query_raw( Err(retired_cloud_transport_error(operation))
&transport.function_name,
transport.args_json.clone(),
cli_ctx,
)
} }
fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> { fn retired_cloud_transport_error(operation: &str) -> CliError {
execute_convex_mutation_raw( CliError::validation(format!(
&transport.function_name, "旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径"
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 build_runtime_context( fn build_runtime_context(
@@ -2341,6 +1797,10 @@ fn build_runtime_context(
source: RuntimeSourceWire { source: RuntimeSourceWire {
channel: "cli".into(), channel: "cli".into(),
client: "mnote-cli".into(), client: "mnote-cli".into(),
source_kind: None,
root_uri: None,
workspace_id: None,
capabilities: Vec::new(),
}, },
tenant_id: None, tenant_id: None,
auth_token: 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> { fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult<String> {
args.get("documentId") args.get("documentId")
.and_then(Value::as_str) .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")) .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( fn build_query_output(
domain: &str, domain: &str,
action: &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) 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] #[test]
fn page_move_json_contract_uses_documents_move() { fn page_move_json_contract_uses_documents_move() {
let output = plan_page_move( let output = plan_page_move(
@@ -3033,55 +2341,4 @@ mod tests {
_ => panic!("expected command output"), _ => 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"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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 = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
tokio-tungstenite = "0.29" tokio-tungstenite = "0.29"
@@ -141,7 +141,7 @@ import {
}; };
const pageAggregateUrlFromDescriptor = (descriptor) => { const pageAggregateUrlFromDescriptor = (descriptor) => {
const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); 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.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId);
if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri);
return url; return url;
@@ -151,10 +151,10 @@ import {
documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId, documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId,
workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '', workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '',
paneRole, paneRole,
sourceKind: descriptor.sourceKind || 'convex_workspace', sourceKind: descriptor.sourceKind || 'local_folder',
rootUri: descriptor.rootUri || '', rootUri: descriptor.rootUri || '',
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', 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/page-body/write'
: '/api/documents/save', : '/api/documents/save',
titleEndpoint: '/api/documents/title', titleEndpoint: '/api/documents/title',
@@ -225,6 +225,48 @@ import {
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim(); if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
return ''; 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; let nextViewId = 1;
const mindmapHost = createMindmapHostRuntime({ const mindmapHost = createMindmapHostRuntime({
@@ -267,12 +309,18 @@ import {
const markSessionExternalConflict = (...args) => documentSessions.markSessionExternalConflict(...args); const markSessionExternalConflict = (...args) => documentSessions.markSessionExternalConflict(...args);
const queueSessionSave = (...args) => documentSessions.queueSessionSave(...args); const queueSessionSave = (...args) => documentSessions.queueSessionSave(...args);
const refreshSessionFromExternalChange = (...args) => documentSessions.refreshSessionFromExternalChange(...args); const refreshSessionFromExternalChange = (...args) => documentSessions.refreshSessionFromExternalChange(...args);
const ensureLocalFolderEventChannel = (...args) => documentSessions.ensureLocalFolderEventChannel(...args);
const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args); const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args);
const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args); const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args);
const updatePaneChrome = (runtimeDescriptor) => { const updatePaneChrome = (runtimeDescriptor) => {
const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]');
if (!(pane instanceof HTMLElement)) return; 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 aggregate = runtimeDescriptor.aggregate || {};
const bootstrap = runtimeDescriptor.bootstrap || {}; const bootstrap = runtimeDescriptor.bootstrap || {};
const title = aggregate?.head?.title || '无标题'; const title = aggregate?.head?.title || '无标题';
@@ -366,9 +414,94 @@ import {
return payload.result; 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 = {}) => { const replacePaneDocument = async (paneRole, descriptor, options = {}) => {
mindmapHost.unmountMindmapPane(paneRole); 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}"]`); const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`);
if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`); if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`);
const runtimePromise = loadRuntime(); const runtimePromise = loadRuntime();
@@ -428,14 +561,14 @@ import {
return { return {
documentId, documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', 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') || '', rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '',
}; };
} }
return { return {
documentId, documentId,
workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', 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') || '', rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '',
}; };
}; };
@@ -529,6 +662,11 @@ import {
broadcastSessionContent(session, view); broadcastSessionContent(session, view);
} }
if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) { 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); markSessionExternalConflict(session, externalConflictMessage);
return; return;
} }
@@ -629,6 +767,9 @@ import {
} else { } else {
setStatus(runtimeDescriptor, 'ready'); 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(); enhanceEditorAttachmentLinksSoon();
}; };
view.onError = (event) => { view.onError = (event) => {
@@ -674,6 +815,7 @@ import {
resourceTabs = createResourceTabRuntime({ resourceTabs = createResourceTabRuntime({
loadRuntime, loadRuntime,
createEditorViewBinding, createEditorViewBinding,
ensureLocalFolderEventChannel,
unmountEditorViewBinding, unmountEditorViewBinding,
setStatus, setStatus,
documentSessionRegistry, documentSessionRegistry,
@@ -828,7 +970,16 @@ import {
}, { once: true }); }, { once: true });
if (paneRuntimes.length) { 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); console.error('mnote multi-pane editor mount failed', error);
}); });
} }
@@ -1,12 +1,45 @@
import { import {
localMarkdownDocumentIdFromRelativePath, localMarkdownDocumentIdFromRelativePath,
localMarkdownRelativePathFromDocumentId,
localizeTiptapAssetUrls, localizeTiptapAssetUrls,
} from './document-tiptap-conversion-runtime.js'; } 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 = {}) => { export const createResourceTabRuntime = (dependencies = {}) => {
const { const {
loadRuntime, loadRuntime,
createEditorViewBinding, createEditorViewBinding,
ensureLocalFolderEventChannel,
unmountEditorViewBinding, unmountEditorViewBinding,
setStatus, setStatus,
documentSessionRegistry, documentSessionRegistry,
@@ -62,9 +95,31 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return 'file'; 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 currentWebShellWorkspaceId = () => {
const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : ''; const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : '';
if (explicit) return explicit; 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 { try {
return currentUrl().searchParams.get('workspaceId') || ''; return currentUrl().searchParams.get('workspaceId') || '';
} catch (_) { } catch (_) {
@@ -82,6 +137,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return ''; 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 normalizeResourceTabKind = (input) => {
const kind = String(input?.kind || '').trim().toLowerCase(); const kind = String(input?.kind || '').trim().toLowerCase();
const title = String(input?.title || input?.fileName || input?.path || '').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 setResourceTabLastActive = (key) => {
const entry = resourceTabRegistry.get(String(key || '').trim()); 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) => { const removeFromResourceTabMru = (paneRole, key) => {
@@ -140,6 +272,10 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const resourceTabCloseGuardReason = (session) => { const resourceTabCloseGuardReason = (session) => {
if (!session) return ''; if (!session) return '';
const bufferDirtyState = String(session.bufferDirtyState || '').trim();
if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') {
return bufferDirtyState;
}
const hasUnsavedChanges = session.dirty const hasUnsavedChanges = session.dirty
|| Boolean(session.saveTimer) || Boolean(session.saveTimer)
|| sessionHasRecentLocalInput(session) || sessionHasRecentLocalInput(session)
@@ -150,7 +286,49 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return ''; 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; if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
const reason = resourceTabCloseGuardReason(entry.session); const reason = resourceTabCloseGuardReason(entry.session);
if (reason) { 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) => { const syncResourceSessionTabGuards = (session) => {
if (!session || session.sessionKind !== 'resource') return; if (!session || session.sessionKind !== 'resource') return;
resourceTabRegistry.forEach((entry) => { resourceTabRegistry.forEach((entry) => {
@@ -169,52 +371,126 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}); });
}; };
const openEditorsSnapshotEntry = (entry, key) => ({ const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
objectIdentity: String(entry?.objectIdentity || key || '').trim(), 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() || '资源', title: String(entry?.title || '资源').trim() || '资源',
kind: normalizeResourceTabKind(entry), kind,
editorKind: kind,
badgeKind: entry?.tab instanceof HTMLElement badgeKind: entry?.tab instanceof HTMLElement
? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim()
: resourceTabBadgeKind(entry, entry?.kind), : resourceTabBadgeKind(entry, entry?.kind),
active: entry?.tab instanceof HTMLElement active,
? entry.tab.getAttribute('aria-selected') === 'true' dirtyState,
: false, dirtyGuard: dirtyState,
dirtyGuard: resourceTabCloseGuardReason(entry?.session), assetId,
assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(), path: relativePath,
path: String(entry?.path || entry?.session?.resourcePath || '').trim(), lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
}); preview: false,
pinned: false,
};
};
const buildOpenEditorsSnapshot = () => { const buildOpenEditorsSnapshot = () => {
const generatedAt = Date.now();
const pageEntries = ['primary', 'secondary'].map((paneRole) => { const pageEntries = ['primary', 'secondary'].map((paneRole) => {
const nodes = resourceTabHostNodes(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 const pageTitle = nodes.pageTab instanceof HTMLElement
? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim()
: '页面'; : '页面';
return { return {
objectIdentity: `page:${paneRole}`, objectIdentity,
workspacePath: buildWorkspacePath({
workspaceId,
sourceKind,
rootUri,
relativePath,
documentId,
objectIdentity,
assetId: '',
resourceKind: 'page',
}),
paneRole, paneRole,
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(), documentId,
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), workspaceId,
title: pageTitle || '页面', title: pageTitle || '页面',
kind: 'page', kind: 'page',
editorKind: 'page',
badgeKind: 'code', badgeKind: 'code',
active: nodes.pageTab instanceof HTMLElement active,
? nodes.pageTab.getAttribute('aria-selected') === 'true' dirtyState,
: false, dirtyGuard: dirtyState,
dirtyGuard: '', 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"]')); }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]'));
const resources = []; const resources = [];
resourceTabRegistry.forEach((entry, key) => { 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 { return {
schema: 'mnote.open_editors_snapshot.v1', schema: 'mnote.open_editors_snapshot.v1',
generatedAt: Date.now(), generatedAt,
activeObjectIdentity: active?.objectIdentity || '', activeObjectIdentity,
editors: [...pageEntries, ...resources], editors,
resourceEditors: resources, 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 syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => {
const role = normalizePaneRole(paneRole); const role = normalizePaneRole(paneRole);
if (role !== 'primary') return; if (role !== 'primary') return;
@@ -340,6 +639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
syncResourceTabCloseGuard(entry); syncResourceTabCloseGuard(entry);
}); });
syncActiveResourceWidthType(activeEntry, role);
if (activeEntry) markIntendedSlashRoot(activeEntry); if (activeEntry) markIntendedSlashRoot(activeEntry);
if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null;
if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); if (role === 'primary') syncActiveResourceFileTreeRow(activeResource);
@@ -399,10 +699,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
syncOpenEditorsSnapshot(); syncOpenEditorsSnapshot();
}; };
const closeResourceTab = (objectIdentity) => { const closeResourceTab = async (objectIdentity) => {
const key = String(objectIdentity || '').trim(); const key = String(objectIdentity || '').trim();
const entry = resourceTabRegistry.get(key); const entry = resourceTabRegistry.get(key);
if (!entry) return; if (!entry) return;
await refreshResourceTabBufferState(entry);
const guardReason = resourceTabCloseGuardReason(entry.session); const guardReason = resourceTabCloseGuardReason(entry.session);
if (guardReason) { if (guardReason) {
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`); console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
@@ -489,6 +790,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
paneRole, paneRole,
title, title,
kind, kind,
badgeKind: resourceTabBadgeKind(input, kind),
tab, tab,
panel, panel,
view: null, view: null,
@@ -497,6 +799,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
path: String(input.path || '').trim(), path: String(input.path || '').trim(),
documentId: String(input.documentId || '').trim(), documentId: String(input.documentId || '').trim(),
ownerDocumentId: String(input.ownerDocumentId || 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; entry.mindmapRuntime = null;
} }
if (entry.resourceWatchEventSource) {
try {
entry.resourceWatchEventSource.close();
} catch (_) {}
entry.resourceWatchEventSource = null;
}
if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren(); if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren();
}; };
@@ -525,6 +848,80 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return url.toString(); 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 createResourceSession = (entry, input, readResult) => {
const resourcePath = String(input.path || ''); const resourcePath = String(input.path || '');
const tiptapDocument = localizeTiptapAssetUrls( const tiptapDocument = localizeTiptapAssetUrls(
@@ -577,6 +974,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
error: null, error: null,
}; };
documentSessionRegistry.set(session.key, session); documentSessionRegistry.set(session.key, session);
if (typeof ensureLocalFolderEventChannel === 'function') {
ensureLocalFolderEventChannel(session);
}
return session; return session;
}; };
@@ -644,6 +1044,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
img.src = href; img.src = href;
img.alt = entry.title; img.alt = entry.title;
} }
installPassiveResourceWatch(entry);
return; return;
} }
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>'; entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
@@ -652,6 +1053,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
frame.title = entry.title; frame.title = entry.title;
frame.src = href; frame.src = href;
} }
installPassiveResourceWatch(entry);
}; };
const refreshExistingOfficeResourceTab = (entry, input) => { const refreshExistingOfficeResourceTab = (entry, input) => {
@@ -5,6 +5,7 @@ import {
hydrateMindmapAttrsFromDom, hydrateMindmapAttrsFromDom,
legacyBlocksFromEditorDocument, legacyBlocksFromEditorDocument,
pageBodyTiptapDocument, pageBodyTiptapDocument,
pageBodyTiptapDocumentSource,
revisionFromConflictKey, revisionFromConflictKey,
textToTiptapDocument, textToTiptapDocument,
toTiptapDocument, toTiptapDocument,
@@ -102,7 +103,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const normalizeSessionSourceKind = (bootstrap) => { const normalizeSessionSourceKind = (bootstrap) => {
const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : '';
return value || 'convex_workspace'; return value || 'local_folder';
}; };
const buildDocumentSessionKey = (bootstrap) => { const buildDocumentSessionKey = (bootstrap) => {
@@ -115,7 +116,90 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const sessionViews = (session) => Array.from(session.views.values()); 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 detachSessionFromLocalFolderChannel = (session) => {
const channel = session.localFolderChannel; const channel = session.localFolderChannel;
@@ -416,16 +500,21 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const ensureSessionConflictEnvelope = (session, message) => { const ensureSessionConflictEnvelope = (session, message) => {
if (!session) return null; if (!session) return null;
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
const isResourceSession = session.sessionKind === 'resource';
const defaultMessage = isResourceSession
? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突'
: externalConflictMessage;
const envelope = { const envelope = {
code: 'local_markdown_external_change', code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change',
documentId: session.documentId, documentId: session.documentId,
rootUri: session.rootUri || null, rootUri: session.rootUri || null,
path: isResourceSession ? (session.resourcePath || '') : undefined,
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
externalActor: session.lastExternalWriteSource || null, externalActor: session.lastExternalWriteSource || null,
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, 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'], suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
}; };
session.lastExternalConflictEnvelope = envelope; session.lastExternalConflictEnvelope = envelope;
@@ -802,13 +891,13 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const markSessionExternalConflict = (session, message) => { const markSessionExternalConflict = (session, message) => {
session.externalChangePending = false; session.externalChangePending = false;
ensureSessionConflictEnvelope(session, message); const envelope = ensureSessionConflictEnvelope(session, message);
session.hasExternalConflict = true; session.hasExternalConflict = true;
if (session.saveTimer) { if (session.saveTimer) {
window.clearTimeout(session.saveTimer); window.clearTimeout(session.saveTimer);
session.saveTimer = 0; session.saveTimer = 0;
} }
const nextMessage = message || externalConflictMessage; const nextMessage = envelope?.message || message || externalConflictMessage;
setSessionStatus(session, 'external-change-conflict', nextMessage); setSessionStatus(session, 'external-change-conflict', nextMessage);
renderSessionConflictSurface(session, nextMessage); renderSessionConflictSurface(session, nextMessage);
}; };
@@ -817,12 +906,23 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (session.readOnly || session.hasExternalConflict) return; if (session.readOnly || session.hasExternalConflict) return;
if (session.saveTimer) window.clearTimeout(session.saveTimer); if (session.saveTimer) window.clearTimeout(session.saveTimer);
setSessionStatus(session, 'dirty'); setSessionStatus(session, 'dirty');
markSessionBufferDirty(session);
session.saveTimer = window.setTimeout(() => { session.saveTimer = window.setTimeout(() => {
session.saveTimer = 0; session.saveTimer = 0;
void persistSession(session); void persistSession(session);
}, 650); }, 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) => { const persistSession = async (session) => {
if (session.readOnly || session.saving || session.hasExternalConflict) return; if (session.readOnly || session.saving || session.hasExternalConflict) return;
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; 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 editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument); const content = legacyBlocksFromEditorDocument(editorDocument);
const saveEndpoint = session.saveEndpoint || '/api/documents/save'; 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' const savePayload = session.sessionKind === 'resource'
? { ? {
rootUri: session.rootUri, rootUri: session.rootUri,
path: session.resourcePath, path: session.resourcePath,
expectedFileVersion: session.conflictDetectionKey, expectedFileVersion: expectedFileVersion,
writeIntentId,
saveOperationId,
contentFormat: 'editorBlocks', contentFormat: 'editorBlocks',
editorSource: 'tiptap-resource-tab', editorSource: 'tiptap-resource-tab',
editorDocument, editorDocument,
@@ -859,7 +978,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sourceKind: session.sourceKind, sourceKind: session.sourceKind,
rootUri: session.rootUri, rootUri: session.rootUri,
revision: session.revision, revision: session.revision,
expectedFileVersion: session.conflictDetectionKey, expectedFileVersion: expectedFileVersion,
writeIntentId,
saveOperationId,
contentFormat: 'editorBlocks', contentFormat: 'editorBlocks',
editorSource: 'tiptap', editorSource: 'tiptap',
editorDocument, editorDocument,
@@ -889,6 +1010,16 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
throw new Error(message); throw new Error(message);
} }
const saved = result.result || {}; 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 (Number.isInteger(saved.revision)) session.revision = saved.revision;
if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) {
session.conflictDetectionKey = 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; if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
session.hasExternalConflict = false; session.hasExternalConflict = false;
session.externalChangePending = false; session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
session.lastUserInputAt = 0; session.lastUserInputAt = 0;
syncSessionMetaToViews(session); syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized; session.lastPersistedSerialized = serialized;
@@ -951,7 +1083,47 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (document.hidden) return; if (document.hidden) return;
if (!session || session.views.size === 0) return; if (!session || session.views.size === 0) return;
if (session.sourceKind === 'local_folder' && !session.rootUri) return; if (session.sourceKind === 'local_folder' && !session.rootUri) return;
if (session.sessionKind === 'resource') {
await refreshResourceSessionFromExternalChange(session, source);
return;
}
try { 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({ const response = await fetch(pageAggregateUrl({
documentId: session.documentId, documentId: session.documentId,
sourceKind: session.sourceKind, sourceKind: session.sourceKind,
@@ -999,13 +1171,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextSerialized = JSON.stringify(nextTiptapDocument); const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized; const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false; session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return; if (!contentChanged) return;
} }
if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; if (nextConflictKey === session.lastExternalConflictDetectionKey) {
if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
markSessionExternalConflict(session, externalConflictMessage); session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
syncSessionMetaToViews(session);
}
return; return;
} }
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); 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) => { const refreshSessionFromExternalFileChange = async (session) => {
if (session.sourceKind !== 'local_folder') return; if (session.sourceKind !== 'local_folder') return;
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
@@ -1045,12 +1293,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (!channel) { if (!channel) {
const url = new URL('/api/local-folder/events', window.location.origin); const url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', session.rootUri); url.searchParams.set('rootUri', session.rootUri);
if (session.sessionKind === 'resource') {
url.searchParams.set('resourcePath', resourceWatchPath(session));
} else {
url.searchParams.set('documentId', session.documentId); url.searchParams.set('documentId', session.documentId);
}
const eventSource = new EventSource(url.toString()); const eventSource = new EventSource(url.toString());
channel = { channel = {
key: channelKey, key: channelKey,
rootUri: session.rootUri, rootUri: session.rootUri,
documentId: session.documentId, documentId: session.documentId,
resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '',
eventSource, eventSource,
sessions: new Map(), sessions: new Map(),
}; };
@@ -1060,6 +1313,19 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
Array.from(channel.sessions.values()).forEach((targetSession) => { Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return; if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; 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; if (!documentId) return;
const eventKind = String(payload.eventKind || ''); const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
@@ -1076,10 +1342,6 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
} }
targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true; 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'); scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
}); });
}); });
@@ -1277,11 +1539,67 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
return scheduled; 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:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
}); });
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
applyLocalUploadEditorSave(event?.detail || {});
});
const createDocumentSession = (runtimeDescriptor) => { const createDocumentSession = (runtimeDescriptor) => {
const pageBody = runtimeDescriptor.aggregate.body || {}; const pageBody = runtimeDescriptor.aggregate.body || {};
@@ -1291,6 +1609,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const keyRevision = revisionFromConflictKey(conflictDetectionKey); const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
const session = { const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId, documentId: runtimeDescriptor.bootstrap.documentId,
@@ -1301,6 +1620,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate, latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题', 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, currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument), currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument), lastPersistedSerialized: JSON.stringify(tiptapDocument),
@@ -1308,6 +1630,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
conflictDetectionKey, conflictDetectionKey,
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey || '', lastExternalConflictDetectionKey: conflictDetectionKey || '',
relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId),
bufferDirtyState: 'Clean',
readOnly: Boolean(permissions.readOnly), readOnly: Boolean(permissions.readOnly),
dirty: false, dirty: false,
saving: false, saving: false,
@@ -1355,6 +1679,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
markSessionExternalConflict, markSessionExternalConflict,
queueSessionSave, queueSessionSave,
refreshSessionFromExternalChange, refreshSessionFromExternalChange,
ensureLocalFolderEventChannel,
releaseDocumentSession, releaseDocumentSession,
scheduleDocumentSessionRelease, scheduleDocumentSessionRelease,
sessionHasRecentExternalSignal, sessionHasRecentExternalSignal,
@@ -394,8 +394,18 @@ export const localizeTiptapAssetUrls = (node, context) => {
return node; 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) { 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); return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context);
} }
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
@@ -1,7 +1,122 @@
// MNote 文件树上下文菜单运行时外置模块。 // MNote 文件树上下文菜单运行时外置模块。
// 当前先承接 CommandContext 构造与 when 表达式求值,树语义仍由 Rust/kernel 主导。 // 当前先承接 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) { 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 selection = deps && deps.selection ? deps.selection : {};
var rowIds = Array.isArray(selection.selectedRowIds) var rowIds = Array.isArray(selection.selectedRowIds)
? selection.selectedRowIds ? selection.selectedRowIds
@@ -181,7 +296,9 @@ function tokenizeWhenExpression(expr) {
} }
window.__mnoteFileTreeContextMenuRuntime = { window.__mnoteFileTreeContextMenuRuntime = {
buildFileTreeCommandContext: buildFileTreeCommandContext,
buildSidebarFileTreeContext: buildSidebarFileTreeContext, buildSidebarFileTreeContext: buildSidebarFileTreeContext,
buildLegacySidebarFileTreeContext: buildLegacySidebarFileTreeContext,
evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen, evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen,
tokenizeWhenExpression: tokenizeWhenExpression tokenizeWhenExpression: tokenizeWhenExpression
}; };
@@ -42,6 +42,10 @@ function handleFileTreeKeyDown(event, deps) {
if (event.key === 'F2') { if (event.key === 'F2') {
event.preventDefault(); event.preventDefault();
var renameCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')) {
return true;
}
beginFileTreeInlineRename(fileTreeRowForKey); beginFileTreeInlineRename(fileTreeRowForKey);
return true; return true;
} }
@@ -61,6 +65,10 @@ function handleFileTreeKeyDown(event, deps) {
} }
if (shortcutKey === 'v') { if (shortcutKey === 'v') {
event.preventDefault(); event.preventDefault();
var pasteCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(pasteCtx, '!workspace.readonly')) {
return true;
}
if (typeof deps.pasteSidebarFileTreeClipboard === 'function') { if (typeof deps.pasteSidebarFileTreeClipboard === 'function') {
void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey); void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey);
} }
@@ -70,8 +78,8 @@ function handleFileTreeKeyDown(event, deps) {
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault(); event.preventDefault();
var delCtx = buildSidebarFileTreeContext('filetree'); var delCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) { if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')) {
return true; return true;
} }
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey); void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
@@ -70,6 +70,57 @@ function fileTreeRowLocalUploadTargetRelativePath(row, deps) {
return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : ''; 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) { function isFileTreeDownloadableAssetRow(row, deps) {
if (!(row instanceof HTMLElement)) return false; if (!(row instanceof HTMLElement)) return false;
var kind = fileTreeRowKind(row); var kind = fileTreeRowKind(row);
@@ -98,6 +149,7 @@ function fileTreeAssetDownloadDetail(row, deps) {
rowKind: fileTreeRowKind(row), rowKind: fileTreeRowKind(row),
assetId: assetId, assetId: assetId,
localRelativePath: relativePath, localRelativePath: relativePath,
workspacePath: readWorkspacePathFromRow(row, deps),
title: title, title: title,
fileName: title, fileName: title,
workspaceId: resolveWorkspaceId(row) workspaceId: resolveWorkspaceId(row)
@@ -274,6 +326,49 @@ function revealFileTreeAssetRow(assetId, deps) {
return true; 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) { function appendUploadedAssetRow(asset, documentId, deps) {
deps = deps || {}; deps = deps || {};
var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); }; 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))) { if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) {
objectKind = 'mindmap'; objectKind = 'mindmap';
} }
var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim(); var localRelativePath = localUploadedAssetRelativePath(asset, deps);
var parentRow = targetDocumentId 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) + '"]') ? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]')
: null; : null;
if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false; if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false;
@@ -325,16 +422,20 @@ function appendUploadedAssetRow(asset, documentId, deps) {
objectKind: objectKind || 'attachment', objectKind: objectKind || 'attachment',
documentId: targetDocumentId || null, documentId: targetDocumentId || null,
blockId: 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); li.setAttribute('data-node-id', 'asset:' + assetId);
var title = uploadedAssetTitle(asset); var title = uploadedAssetTitle(asset);
var iconKind = fileTreeIconKindForFileName(title) || uploadedAssetType(asset) || 'file'; var iconKind = fileTreeIconKindForFileName(title) || uploadedAssetType(asset) || 'file';
if (objectKind === 'mindmap') iconKind = 'mindmap'; 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 = 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>' + '<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>'; '<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); container.appendChild(li);
revealFileTreeRow(li.querySelector('.tree-row')); revealFileTreeRow(li.querySelector('.tree-row'));
@@ -584,6 +685,7 @@ window.__mnoteFileTreeRuntime = {
decodeLocalEncodedPath: decodeLocalEncodedPath, decodeLocalEncodedPath: decodeLocalEncodedPath,
fileTreeRowLocalRelativePath: fileTreeRowLocalRelativePath, fileTreeRowLocalRelativePath: fileTreeRowLocalRelativePath,
fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath, fileTreeRowLocalUploadTargetRelativePath: fileTreeRowLocalUploadTargetRelativePath,
readWorkspacePathFromRow: readWorkspacePathFromRow,
isFileTreeDownloadableAssetRow: isFileTreeDownloadableAssetRow, isFileTreeDownloadableAssetRow: isFileTreeDownloadableAssetRow,
isFileTreeDownloadableRow: isFileTreeDownloadableRow, isFileTreeDownloadableRow: isFileTreeDownloadableRow,
fileTreeAssetDownloadDetail: fileTreeAssetDownloadDetail, fileTreeAssetDownloadDetail: fileTreeAssetDownloadDetail,
@@ -55,15 +55,91 @@ function dispatchUploadedEditorChange(editorRoot, editor, deps) {
})); }));
} }
function currentConflictDetectionKey() { function firstNonEmptyString(values) {
var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__'); for (var i = 0; i < values.length; i += 1) {
if (!script) return ''; var value = String(values[i] || '').trim();
try { if (value) return value;
var body = JSON.parse(script.textContent || 'null');
return String(body && (body.conflictDetectionKey || body.conflict_detection_key || body.fileVersion || body.file_version) || '').trim();
} catch (_) {
return '';
} }
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) { function persistLocalFolderSelfChangeSuppression(documentId, expiresAt) {
@@ -101,7 +177,9 @@ async function persistUploadedEditorChange(editor, asset, deps) {
documentId: documentId, documentId: documentId,
sourceKind: 'local_folder', sourceKind: 'local_folder',
rootUri: rootUri, rootUri: rootUri,
expectedFileVersion: currentConflictDetectionKey(), expectedFileVersion: currentConflictDetectionKey(documentId, rootUri),
writeIntentId: makeSaveOperationToken('intent:local-upload', documentId),
saveOperationId: makeSaveOperationToken('save:local-upload', documentId),
contentFormat: 'editorBlocks', contentFormat: 'editorBlocks',
editorSource: 'local-upload-runtime', editorSource: 'local-upload-runtime',
editorDocument: editorDocument, editorDocument: editorDocument,
@@ -120,10 +198,32 @@ async function persistUploadedEditorChange(editor, asset, deps) {
if (!response.ok || !result || result.ok !== true) { if (!response.ok || !result || result.ok !== true) {
var message = result && (result.message || (result.error && result.error.message)) || ('save_failed_' + response.status); var message = result && (result.message || (result.error && result.error.message)) || ('save_failed_' + response.status);
document.documentElement.setAttribute('data-mnote-last-upload-save-error', message); 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); 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.setAttribute('data-mnote-last-upload-saved', 'true');
document.documentElement.removeAttribute('data-mnote-last-upload-save-error'); 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() { 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-inserted', 'false');
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed'); 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 (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
if (typeof deps.enhanceEditorAttachmentLinks === 'function') deps.enhanceEditorAttachmentLinks(); if (typeof deps.enhanceEditorAttachmentLinks === 'function') deps.enhanceEditorAttachmentLinks();
var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value || '').replace(/"/g, '\\"'); }; 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 var link = targetRoot instanceof HTMLElement
? targetRoot.querySelector(selector) ? targetRoot.querySelector(selector)
: document.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) { if (link instanceof HTMLElement) {
link.setAttribute('data-mnote-attachment-link', 'true'); link.setAttribute('data-mnote-attachment-link', 'true');
if (assetId) link.setAttribute('data-asset-id', assetId); if (assetId) link.setAttribute('data-asset-id', assetId);
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel); 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; return inserted;
} }
} catch (error) { } catch (error) {
@@ -434,6 +544,18 @@ async function uploadFileToMediaAsset(file, plan, options, deps) {
if (getCurrentSourceKind() === 'local_folder') { if (getCurrentSourceKind() === 'local_folder') {
var rootUri = getCurrentRootUri(); var rootUri = getCurrentRootUri();
var documentId = String(plan && plan.targetDocumentId || getCurrentDocumentId() || '').trim(); 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, { var localResult = await uploadLocalFolderAsset(file, plan, {
rootUri: rootUri, rootUri: rootUri,
documentId: documentId, documentId: documentId,
@@ -446,7 +568,7 @@ async function uploadFileToMediaAsset(file, plan, options, deps) {
if (options.insertIntoEditor) { if (options.insertIntoEditor) {
await insertIntoEditor(localAsset, resolveEditorRoot(options)); await insertIntoEditor(localAsset, resolveEditorRoot(options));
} }
if (typeof deps.refreshLocalFolderSidebarSnapshot === 'function') { if (!options.insertIntoEditor && typeof deps.refreshLocalFolderSidebarSnapshot === 'function') {
void deps.refreshLocalFolderSidebarSnapshot(); void deps.refreshLocalFolderSidebarSnapshot();
} }
dispatch(new EventCtor('wolai:local-assets-changed', { dispatch(new EventCtor('wolai:local-assets-changed', {
@@ -528,6 +650,9 @@ function fileTreeIconKindForFileName(fileName) {
if (ext === 'md' || ext === 'markdown') return 'markdown'; if (ext === 'md' || ext === 'markdown') return 'markdown';
if (ext === 'pdf') return 'pdf'; if (ext === 'pdf') return 'pdf';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image'; 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 ''; return '';
} }
@@ -23,7 +23,50 @@ function buildLocalFileOpenUrl(relativePath, download) {
return url.toString(); 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) { function buildOnlyOfficeOpenPath(input) {
if (shouldUseOfficePreview(input.fileType, input.mode)) {
return buildOfficePreviewOpenUrl(input);
}
var params = new URLSearchParams(); var params = new URLSearchParams();
params.set('fileUrl', input.fileUrl || ''); params.set('fileUrl', input.fileUrl || '');
params.set('fileName', input.fileName || '未命名资源'); 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 = { window.__mnoteResourceOpenRuntime = {
localFilePathFromAssetId: localFilePathFromAssetId, localFilePathFromAssetId: localFilePathFromAssetId,
buildLocalFileOpenUrl: buildLocalFileOpenUrl, buildLocalFileOpenUrl: buildLocalFileOpenUrl,
buildPdfPreviewOpenUrl: buildPdfPreviewOpenUrl,
buildOfficePreviewOpenUrl: buildOfficePreviewOpenUrl,
buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath buildOnlyOfficeOpenPath: buildOnlyOfficeOpenPath
}; };
@@ -5,12 +5,14 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
buildLocalOnlyOfficeOpenUrl, buildLocalOnlyOfficeOpenUrl,
buildOnlyOfficeOpenPath, buildOnlyOfficeOpenPath,
buildOnlyOfficeOpenUrl, buildOnlyOfficeOpenUrl,
buildPdfPreviewOpenUrl: injectedBuildPdfPreviewOpenUrl,
closestAction: injectedClosestAction, closestAction: injectedClosestAction,
currentDocumentId, currentDocumentId,
currentRootUri, currentRootUri,
currentWorkspaceSourcePayload, currentWorkspaceSourcePayload,
fileTreeIconKindForFileName, fileTreeIconKindForFileName,
healLegacyOfficeAttachmentParagraphs, healLegacyOfficeAttachmentParagraphs,
hydrateEditorAttachmentMeta: injectedHydrateEditorAttachmentMeta,
inferCodeAttachmentLanguage, inferCodeAttachmentLanguage,
inferOnlyOfficeFileType, inferOnlyOfficeFileType,
isCodeAttachmentFileName, isCodeAttachmentFileName,
@@ -28,9 +30,15 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target; var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
return node && typeof node.closest === 'function' ? node.closest(selector) : null; return node && typeof node.closest === 'function' ? node.closest(selector) : null;
}; };
const hydrateEditorAttachmentMeta = typeof injectedHydrateEditorAttachmentMeta === 'function'
? injectedHydrateEditorAttachmentMeta
: async function() {};
var activeEditorAttachmentLink = null; var activeEditorAttachmentLink = null;
var attachmentActionsHideTimer = 0; 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) { function attachmentQueryParams(href) {
try { 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) { function buildLocalFileStatusUrl(relativePath, rootUri) {
var effectiveRootUri = String(rootUri || currentRootUri() || '').trim(); var effectiveRootUri = String(rootUri || currentRootUri() || '').trim();
if (!effectiveRootUri || !relativePath) return ''; if (!effectiveRootUri || !relativePath) return '';
@@ -69,13 +84,26 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
return url.toString(); 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) { function setEditorAttachmentMissingState(link, missing) {
if (!(link instanceof HTMLAnchorElement)) return; if (!(link instanceof HTMLAnchorElement)) return;
var value = Boolean(missing); var value = Boolean(missing);
if (value) { 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.classList.add('mnote-uploaded-attachment-missing');
link.setAttribute('aria-label', (link.textContent || '附件') + '(文件不存在)'); setAttributeIfChanged(link, 'aria-label', (link.textContent || '附件') + '(文件不存在)');
} else { } else {
if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return; if (link.getAttribute('data-mnote-attachment-missing') !== 'true') return;
link.removeAttribute('data-mnote-attachment-missing'); 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) { async function refreshLocalAttachmentExistence(link) {
if (!(link instanceof HTMLAnchorElement)) return; if (!(link instanceof HTMLAnchorElement)) return;
var href = link.getAttribute('href') || link.href || ''; 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); var localFilePath = localFileOpenPathFromHref(href);
if (!localFilePath) return; if (!localFilePath) return;
var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href)); var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href));
@@ -94,11 +148,32 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
try { try {
var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' }); var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' });
var payload = await response.json().catch(function() { return null; }); 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); var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true);
setEditorAttachmentMissingState(link, !exists); setEditorAttachmentStatusErrorByHref(href, '');
setEditorAttachmentMissingStateByHref(href, !exists);
} catch (_) {} } 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() { function refreshEditorLocalAttachmentExistence() {
document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) { document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) {
if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link); if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link);
@@ -106,6 +181,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
} }
window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence; window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence;
function scheduleEditorLocalAttachmentExistenceRefresh() {
[120, 500, 1200].forEach(function(delayMs) {
window.setTimeout(refreshEditorLocalAttachmentExistence, delayMs);
});
}
function fileNameFromPath(path) { function fileNameFromPath(path) {
var value = String(path || '').trim(); var value = String(path || '').trim();
return value.indexOf('/') >= 0 ? value.split('/').pop() : value; return value.indexOf('/') >= 0 ? value.split('/').pop() : value;
@@ -225,20 +306,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
|| isOfficeFileName(fileName) || isOfficeFileName(fileName)
|| Boolean(localFilePath); || Boolean(localFilePath);
if (!shouldEnhance) return; 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 : ''); 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) { attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
if (name) link.classList.add(name); if (name) link.classList.add(name);
}); });
link.setAttribute('target', '_blank'); setAttributeIfChanged(link, 'target', '_blank');
link.setAttribute('rel', 'noopener noreferrer nofollow'); setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow');
if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { if (localFilePath && !isOnlyOfficeAttachmentHref(href)) {
setEditorAttachmentMissingState(link, editorAttachmentMissingByKey.has(localFileOpenKeyFromHref(href)));
void refreshLocalAttachmentExistence(link); void refreshLocalAttachmentExistence(link);
return; return;
} }
if (isOnlyOfficeAttachmentHref(href)) { if (isOnlyOfficeAttachmentHref(href)) {
link.setAttribute('href', buildOnlyOfficeOpenPath({ setAttributeIfChanged(link, 'href', buildOnlyOfficeOpenPath({
fileUrl: params.get('fileUrl') || '', fileUrl: params.get('fileUrl') || '',
fileName: fileName || '未命名附件', fileName: fileName || '未命名附件',
fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''), fileType: params.get('fileType') || inferOnlyOfficeFileType(fileName, ''),
@@ -265,6 +347,14 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
enhanceEditorAttachmentLinks(); enhanceEditorAttachmentLinks();
if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer); if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer);
}, 500); }, 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() { function ensureAttachmentActions() {
var existing = document.querySelector('[data-testid="mnote-attachment-actions"]'); var existing = document.querySelector('[data-testid="mnote-attachment-actions"]');
@@ -308,18 +398,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
var localFilePath = localFilePathFromAssetId(detail.assetId); var localFilePath = localFilePathFromAssetId(detail.assetId);
if (localFilePath) { if (localFilePath) {
if (await openLocalOfficeFileInActiveTab(detail, 'view')) return; 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,失败则新窗口 // 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口
void openLocalResourceInActiveTab({ void openLocalResourceInActiveTab({
path: localFilePath, path: localFilePath,
title: detail.fileName || localFilePath.split('/').pop() || localFilePath, title: localFileName,
kind: fileTreeIconKindForFileName(detail.fileName || localFilePath.split('/').pop() || localFilePath), kind: fileTreeIconKindForFileName(localFileName),
assetId: detail.assetId, assetId: detail.assetId,
documentId: detail.documentId || currentDocumentId() || '', documentId: detail.documentId || currentDocumentId() || '',
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
href: buildLocalFileOpenUrl(localFilePath, false), href: localOpenUrl,
paneRole: detail.paneRole || 'primary' paneRole: detail.paneRole || 'primary'
}).then(function(opened) { }).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; return;
} }
@@ -357,7 +450,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
var localFileName = localFilePath.split('/').pop() || localFilePath; var localFileName = localFilePath.split('/').pop() || localFilePath;
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode); var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false); 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; return;
} }
var href = detail.href || detail.fileUrl; var href = detail.href || detail.fileUrl;
@@ -464,7 +558,23 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
async function openPdfEditorAttachment(detail) { async function openPdfEditorAttachment(detail) {
try { try {
var resolved = await resolveEditorAttachmentUrl(detail); 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) { } catch (error) {
window.alert(error && error.message ? error.message : '打开 PDF 失败'); window.alert(error && error.message ? error.message : '打开 PDF 失败');
} }
@@ -612,6 +722,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
window.__mnoteEnhanceEditorAttachmentLinks(); window.__mnoteEnhanceEditorAttachmentLinks();
scheduleEditorAttachmentEnhance(); scheduleEditorAttachmentEnhance();
}); });
window.addEventListener('tree:delta', scheduleEditorLocalAttachmentExistenceRefresh);
window.addEventListener('tree:resync', scheduleEditorLocalAttachmentExistenceRefresh);
[ [
'mnote:leptos-tiptap-spike:ready', 'mnote:leptos-tiptap-spike:ready',
'mnote:leptos-tiptap-spike:change', 'mnote:leptos-tiptap-spike:change',
@@ -634,27 +746,35 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
}); });
function interceptEditorAttachmentLink(event) { 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; if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); 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); openEditorAttachmentLink(editorAttachmentLink);
} }
function suppressEditorAttachmentLinkDefault(event) { 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; if (!(editorAttachmentLink instanceof HTMLAnchorElement)) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation(); 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('mousedown', suppressEditorAttachmentLinkDefault, true);
window.addEventListener('click', interceptEditorAttachmentLink, true); window.addEventListener('click', interceptEditorAttachmentLink, true);
document.addEventListener('mouseover', function(event) { 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 (!(link instanceof HTMLAnchorElement)) return;
if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer); if (attachmentActionsHideTimer) window.clearTimeout(attachmentActionsHideTimer);
enhanceEditorAttachmentLink(link); enhanceEditorAttachmentLink(link);
@@ -662,7 +782,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
}); });
document.addEventListener('mouseout', function(event) { 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; if (!(link instanceof HTMLAnchorElement)) return;
var next = event.relatedTarget; var next = event.relatedTarget;
var actions = document.querySelector('[data-testid="mnote-attachment-actions"]'); var actions = document.querySelector('[data-testid="mnote-attachment-actions"]');
@@ -330,13 +330,18 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
} }
function fileTreeCopyLocalAbsolutePath(detail, trigger) { 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') { if (!rootUri && trigger && typeof trigger.closest === 'function') {
var row = trigger.closest('.tree-row[data-shell-mode="filetree"]'); var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || ''; if (row instanceof HTMLElement) rootUri = row.getAttribute('data-root-uri') || '';
} }
if (!rootUri && document.body) rootUri = document.body.getAttribute('data-mnote-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.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
if (!relativePath && detail && detail.documentId) { if (!relativePath && detail && detail.documentId) {
relativePath = String(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.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('role', 'menuitem');
button.setAttribute('data-action', item.action); 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; button.disabled = item.disabled === true;
if (item.title) button.title = item.title; 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'); var icon = document.createElement('span');
icon.className = 'material-symbols-outlined mnote-tree-context-menu__icon'; icon.className = 'material-symbols-outlined mnote-tree-context-menu__icon';
icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('aria-hidden', 'true');
@@ -636,10 +646,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
// ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ── // ── 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; var runtime = window.__mnoteFileTreeContextMenuRuntime;
if (runtime && typeof runtime.buildSidebarFileTreeContext === 'function') { if (runtime && typeof runtime.buildSidebarFileTreeContext === 'function') {
try { try {
var targetRow = commandContextTargetRow(detail);
return runtime.buildSidebarFileTreeContext(kind, { return runtime.buildSidebarFileTreeContext(kind, {
selection: sidebarFileTreeSelection, selection: sidebarFileTreeSelection,
currentSourceKind: currentSourceKind, currentSourceKind: currentSourceKind,
@@ -648,7 +678,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}, },
queryRowById: function(rowId) { queryRowById: function(rowId) {
return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(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 (_) {} } catch (_) {}
} }
@@ -800,10 +833,17 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
var isAttachment = kind === 'attachment'; var isAttachment = kind === 'attachment';
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index'; var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
var isFileTreeDownload = kind === 'filetree' && detail.downloadable; 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 ? [ var items = isAttachment ? [
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' }, { 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 }, { separator: true },
{ action: 'copy-link', icon: 'link', label: '复制链接' }, { action: 'copy-link', icon: 'link', label: '复制链接' },
{ action: 'move-embed', icon: 'subdirectory_arrow_right', label: '移动/嵌入到...', shortcut: 'Alt+Shift+M/G' }, { 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: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' },
{ action: 'new-window', icon: 'open_in_new', 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: '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: '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 }, { separator: true },
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' }, { action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
{ action: 'refresh', icon: 'refresh', label: 'Refresh' }, { action: 'refresh', icon: 'refresh', label: 'Refresh' },
@@ -849,9 +889,9 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
{ action: 'reveal', icon: 'my_location', label: 'Reveal' }, { action: 'reveal', icon: 'my_location', label: 'Reveal' },
{ separator: true }, { 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 }, { 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+' }, { action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' },
{ separator: true }, { separator: true },
@@ -859,13 +899,18 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' }, { action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' },
{ action: 'copy-id', icon: 'tag', label: '复制页面ID' }, { action: 'copy-id', icon: 'tag', label: '复制页面ID' },
{ separator: true }, { separator: true },
{ action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly' }, { action: 'rename', icon: 'edit', label: '重命名', when: '!workspace.readonly && !editor.dirty' },
{ separator: true }, { 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) { items.forEach(function(item) {
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) { 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); 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 documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
var selectedDownloadRows = selectedSidebarFileTreeRowsForDownload(row); var selectedDownloadRows = selectedSidebarFileTreeRowsForDownload(row);
var selectedAssetRows = selectedDownloadRows.filter(isFileTreeDownloadableAssetRow); var selectedAssetRows = selectedDownloadRows.filter(isFileTreeDownloadableAssetRow);
var workspacePath = readWorkspacePathFromRow(row);
openTreeContextMenu('filetree', { openTreeContextMenu('filetree', {
documentId: documentId, documentId: documentId,
rowId: row.getAttribute('data-row-id') || '', rowId: row.getAttribute('data-row-id') || '',
rowKind: row.getAttribute('data-row-kind') || '', rowKind: row.getAttribute('data-row-kind') || '',
assetId: row.getAttribute('data-asset-id') || '', assetId: row.getAttribute('data-asset-id') || '',
localRelativePath: fileTreeRowLocalRelativePath(row), localRelativePath: fileTreeRowLocalRelativePath(row),
workspacePath: workspacePath,
targetRow: row,
title: rowTitle(row), title: rowTitle(row),
workspaceId: resolveWorkspaceId(row), workspaceId: resolveWorkspaceId(row),
downloadable: isFileTreeDownloadableRow(row), downloadable: isFileTreeDownloadableRow(row),
@@ -1243,6 +1291,32 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : ''; 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) { function fileTreeRowKind(row) {
var runtimeFn = fileTreeRuntimeFunction('fileTreeRowKind'); var runtimeFn = fileTreeRuntimeFunction('fileTreeRowKind');
if (runtimeFn) return runtimeFn(row); if (runtimeFn) return runtimeFn(row);
@@ -1281,6 +1355,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
rowKind: fileTreeRowKind(row), rowKind: fileTreeRowKind(row),
assetId: assetId, assetId: assetId,
localRelativePath: relativePath, localRelativePath: relativePath,
workspacePath: readWorkspacePathFromRow(row),
title: title, title: title,
fileName: title, fileName: title,
workspaceId: resolveWorkspaceId(row) workspaceId: resolveWorkspaceId(row)
@@ -1728,6 +1803,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
decodeLocalEncodedPath, decodeLocalEncodedPath,
fileTreeRowLocalRelativePath, fileTreeRowLocalRelativePath,
fileTreeRowLocalUploadTargetRelativePath, fileTreeRowLocalUploadTargetRelativePath,
readWorkspacePathFromRow,
fileTreeRowKind, fileTreeRowKind,
isFileTreeDownloadableAssetRow, isFileTreeDownloadableAssetRow,
isFileTreeDownloadableRow, isFileTreeDownloadableRow,
@@ -3,16 +3,19 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
copyWorkspaceSourceParams, copyWorkspaceSourceParams,
currentDocumentId, currentDocumentId,
currentRootUri, currentRootUri,
currentSourceKind,
fileTreeIconKindForFileName, fileTreeIconKindForFileName,
getNavigationInFlight, getNavigationInFlight,
isCodeAttachmentFileName, isCodeAttachmentFileName,
isNonOfficeAttachmentName, isNonOfficeAttachmentName,
isPdfAttachmentFileName,
openCodeEditorAttachment, openCodeEditorAttachment,
resolveWorkspaceId, resolveWorkspaceId,
setNavigationInFlight, setNavigationInFlight,
shouldOpenLocalResourceInNewWindow, shouldOpenLocalResourceInNewWindow,
uploadedFileSize, uploadedFileSize,
} = dependencies; } = dependencies;
const currentSourceKindFn = typeof currentSourceKind === 'function' ? currentSourceKind : function() { return ''; };
function inferOnlyOfficeFileType(fileName, mimeType) { function inferOnlyOfficeFileType(fileName, mimeType) {
var name = String(fileName || '').trim().toLowerCase(); var name = String(fileName || '').trim().toLowerCase();
@@ -29,6 +32,9 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
} }
function buildOnlyOfficeOpenUrl(input) { function buildOnlyOfficeOpenUrl(input) {
if (shouldUseOfficePreview(input.fileType, input.mode)) {
return buildOfficePreviewOpenUrl(input);
}
var target = new URL('/onlyoffice', window.location.origin); var target = new URL('/onlyoffice', window.location.origin);
var fileUrl = String(input.fileUrl || '').trim(); var fileUrl = String(input.fileUrl || '').trim();
if (fileUrl) { if (fileUrl) {
@@ -46,11 +52,49 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
return target.toString(); 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) { function buildOnlyOfficeOpenPath(input) {
var _rto_ = window.__mnoteResourceOpenRuntime; var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.buildOnlyOfficeOpenPath === 'function') { if (_rto_ && typeof _rto_.buildOnlyOfficeOpenPath === 'function') {
return _rto_.buildOnlyOfficeOpenPath(input); return _rto_.buildOnlyOfficeOpenPath(input);
} }
if (shouldUseOfficePreview(input.fileType, input.mode)) {
return buildOfficePreviewOpenUrl(input);
}
var params = new URLSearchParams(); var params = new URLSearchParams();
params.set('fileUrl', input.fileUrl || ''); params.set('fileUrl', input.fileUrl || '');
params.set('fileName', input.fileName || '未命名资源'); params.set('fileName', input.fileName || '未命名资源');
@@ -77,12 +121,17 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
assetId: assetId || ('local-file:' + relativePath), assetId: assetId || ('local-file:' + relativePath),
documentId: documentId || currentDocumentId() || '', documentId: documentId || currentDocumentId() || '',
userId: '', userId: '',
mode: mode || 'view' mode: mode || 'view',
workspaceId: resolveWorkspaceId(document.body) || '',
sourceKind: currentSourceKindFn() || 'local_folder',
rootUri: currentRootUri() || ''
}); });
} }
async function openLocalOfficeFileInActiveTab(detail, mode) { async function openLocalOfficeFileInActiveTab(detail, mode) {
var localFilePath = localFilePathFromAssetId(detail.assetId); var localFilePath = localFilePathFromAssetId(detail.assetId);
var workspacePath = workspacePathFromDetail(detail);
if (!localFilePath && workspacePath) localFilePath = String(workspacePath.relativePath || workspacePath.localRelativePath || '').trim();
if (!localFilePath) return false; if (!localFilePath) return false;
var localFileName = localFilePath.split('/').pop() || localFilePath; var localFileName = localFilePath.split('/').pop() || localFilePath;
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, mode || 'view'); 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) || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
href: buildLocalFileOpenUrl(localFilePath, false), href: buildLocalFileOpenUrl(localFilePath, false),
officeUrl: localOfficeUrl, officeUrl: localOfficeUrl,
workspacePath: workspacePath,
paneRole: detail.paneRole || 'primary' paneRole: detail.paneRole || 'primary'
}); });
if (!opened) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); 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) : ''; 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) { function buildLocalFileOpenUrl(relativePath, download) {
var _rto_ = window.__mnoteResourceOpenRuntime; var _rto_ = window.__mnoteResourceOpenRuntime;
if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') { if (_rto_ && typeof _rto_.buildLocalFileOpenUrl === 'function') {
@@ -182,6 +262,19 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
return url.toString(); 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) { async function openLocalResourceInActiveTab(input) {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab !== 'function') return false; if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab !== 'function') return false;
var relativePath = String(input && input.path || '').trim(); var relativePath = String(input && input.path || '').trim();
@@ -189,7 +282,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
if (!relativePath || !rootUri) return false; if (!relativePath || !rootUri) return false;
var title = String(input && input.title || '').trim() || relativePath.split('/').pop() || relativePath; var title = String(input && input.title || '').trim() || relativePath.split('/').pop() || relativePath;
var kind = String(input && input.kind || fileTreeIconKindForFileName(title) || '').trim(); 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({ return await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: objectIdentity, objectIdentity: objectIdentity,
assetId: String(input && input.assetId || '').trim() || 'local-file:' + relativePath, assetId: String(input && input.assetId || '').trim() || 'local-file:' + relativePath,
@@ -202,6 +303,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
officeUrl: String(input && input.officeUrl || '').trim(), officeUrl: String(input && input.officeUrl || '').trim(),
documentId: String(input && input.documentId || currentDocumentId() || '').trim(), documentId: String(input && input.documentId || currentDocumentId() || '').trim(),
workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(), workspaceId: String(input && input.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
workspacePath: workspacePath,
paneRole: String(input && input.paneRole || 'primary').trim() || 'primary' paneRole: String(input && input.paneRole || 'primary').trim() || 'primary'
}); });
} }
@@ -240,15 +342,26 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
var forceEditMode = openTarget === 'edit-mode'; var forceEditMode = openTarget === 'edit-mode';
if (openTarget === 'side') { if (openTarget === 'side') {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') {
var sideWorkspacePath = workspacePathFromDetail(detail);
var sideLocalFilePath = localFilePathFromAssetId(assetId); var sideLocalFilePath = localFilePathFromAssetId(assetId);
if (!sideLocalFilePath && sideWorkspacePath) sideLocalFilePath = String(sideWorkspacePath.relativePath || sideWorkspacePath.localRelativePath || '').trim();
var sideRootUri = String(detail.rootUri || currentRootUri() || '').trim(); var sideRootUri = String(detail.rootUri || currentRootUri() || '').trim();
var sideFileName = String(detail.title || detail.fileName || (sideLocalFilePath ? sideLocalFilePath.split('/').pop() : '') || assetId || '').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 sideKind = String(detail.iconKind || detail.assetType || fileTreeIconKindForFileName(sideFileName) || 'file').trim();
var sideHref = sideLocalFilePath ? buildLocalFileOpenUrl(sideLocalFilePath, false) : ''; var sideHref = sideLocalFilePath ? buildLocalFileOpenUrl(sideLocalFilePath, false) : '';
var sideOfficeUrl = sideLocalFilePath ? buildLocalOnlyOfficeOpenUrl(sideLocalFilePath, sideFileName, String(detail.documentId || currentDocumentId() || '').trim(), assetId, 'view') : ''; var sideOfficeUrl = sideLocalFilePath ? buildLocalOnlyOfficeOpenUrl(sideLocalFilePath, sideFileName, String(detail.documentId || currentDocumentId() || '').trim(), assetId, 'view') : '';
if (sideOfficeUrl) sideKind = 'office'; if (sideOfficeUrl) sideKind = 'office';
if (!sideOfficeUrl && isPdfAttachmentFileName(sideFileName)) sideHref = buildPdfPreviewOpenUrl(sideHref, sideFileName);
void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({ 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, assetId: assetId,
title: sideFileName, title: sideFileName,
fileName: sideFileName, fileName: sideFileName,
@@ -258,12 +371,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
href: sideHref, href: sideHref,
officeUrl: sideOfficeUrl, officeUrl: sideOfficeUrl,
documentId: String(detail.documentId || currentDocumentId() || ''), documentId: String(detail.documentId || currentDocumentId() || ''),
workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || '') workspaceId: String(detail.workspaceId || resolveWorkspaceId(document.body) || ''),
workspacePath: sideWorkspacePath
}); });
} }
return; return;
} }
var detailWorkspacePath = workspacePathFromDetail(detail);
var localFilePath = localFilePathFromAssetId(assetId); var localFilePath = localFilePathFromAssetId(assetId);
if (!localFilePath && detailWorkspacePath) localFilePath = String(detailWorkspacePath.relativePath || detailWorkspacePath.localRelativePath || '').trim();
if (localFilePath) { if (localFilePath) {
var localFileName = localFilePath.split('/').pop() || localFilePath; var localFileName = localFilePath.split('/').pop() || localFilePath;
if (isMindmapAssetDetail(detail) || String(detail && detail.assetType || '').trim() === 'mindmap') { 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-open-mode', 'mindmap-resource-tab');
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ 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, assetId: assetId,
mindmapId: assetId, mindmapId: assetId,
title: String(detail && detail.title || localFileName || '思维导图').trim(), title: String(detail && detail.title || localFileName || '思维导图').trim(),
fileName: localFileName, fileName: localFileName,
kind: 'mindmap', kind: 'mindmap',
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(), documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
workspaceId: String(detail.workspaceId || '').trim() workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
}); });
return; return;
} }
@@ -297,13 +420,15 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
assetId: assetId, assetId: assetId,
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(), documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(), workspaceId: String(detail.workspaceId || '').trim(),
officeUrl: localOfficeUrl officeUrl: localOfficeUrl,
workspacePath: detailWorkspacePath
})) return; })) return;
window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
return; return;
} }
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false); var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
if (localFileUrl) { if (localFileUrl) {
var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl;
var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName); var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);
if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({ if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({
path: localFilePath, path: localFilePath,
@@ -312,9 +437,10 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
assetId: assetId, assetId: assetId,
documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(), documentId: String(detail && detail.documentId || currentDocumentId() || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(), workspaceId: String(detail.workspaceId || '').trim(),
href: localFileUrl href: localOpenUrl,
workspacePath: detailWorkspacePath
})) return; })) return;
window.open(localFileUrl, '_blank', 'noopener,noreferrer'); window.open(localOpenUrl, '_blank', 'noopener,noreferrer');
} }
return; 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-open-mode', 'mindmap-resource-tab');
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:mindmap:' + documentId + ':' + assetId, objectIdentity: resourceObjectIdentityFromWorkspacePath({
workspacePath: detailWorkspacePath,
objectKind: 'mindmap',
documentId: documentId,
assetId: assetId
}),
assetId: assetId, assetId: assetId,
mindmapId: assetId, mindmapId: assetId,
title: String(detail.title || '思维导图').trim(), title: String(detail.title || '思维导图').trim(),
fileName: String(detail.title || '思维导图').trim(), fileName: String(detail.title || '思维导图').trim(),
kind: 'mindmap', kind: 'mindmap',
documentId: documentId, documentId: documentId,
workspaceId: String(detail.workspaceId || '').trim() workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
}); });
return; return;
} }
@@ -367,20 +499,50 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
}); });
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') { if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ 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, assetId: assetId,
title: fileName, title: fileName,
fileName: fileName, fileName: fileName,
kind: 'office', kind: 'office',
officeUrl: officeUrl, officeUrl: officeUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(), documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim() workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
}); });
if (didOpen) return; if (didOpen) return;
} }
window.open(officeUrl, '_blank', 'noopener,noreferrer'); window.open(officeUrl, '_blank', 'noopener,noreferrer');
return; 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()) { if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
await openCodeEditorAttachment({ await openCodeEditorAttachment({
href: fileUrl, href: fileUrl,
@@ -407,8 +569,10 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
buildLocalFileOpenUrl, buildLocalFileOpenUrl,
buildLocalOnlyOfficeOpenUrl, buildLocalOnlyOfficeOpenUrl,
buildMindmapOpenPath, buildMindmapOpenPath,
buildOfficePreviewOpenUrl,
buildOnlyOfficeOpenPath, buildOnlyOfficeOpenPath,
buildOnlyOfficeOpenUrl, buildOnlyOfficeOpenUrl,
buildPdfPreviewOpenUrl,
fetchCurrentOnlyOfficeUserId, fetchCurrentOnlyOfficeUserId,
inferOnlyOfficeFileType, inferOnlyOfficeFileType,
isMindmapAssetDetail, isMindmapAssetDetail,
@@ -418,5 +582,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
openLocalOfficeFileInActiveTab, openLocalOfficeFileInActiveTab,
openLocalResourceInActiveTab, openLocalResourceInActiveTab,
readFileTreeObjectIdentity, readFileTreeObjectIdentity,
resourceObjectIdentityFromWorkspacePath,
workspacePathFromDetail,
}; };
}; };
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,25 @@ export function createSidebarPageSettingsRuntime(context) {
searchText, searchText,
} = context; } = context;
const MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = context.globalShowHeadingNumbersKey || 'mnote.global.showHeadingNumbers'; 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() { function defaultPageOptions() {
return { return {
@@ -44,6 +63,93 @@ export function createSidebarPageSettingsRuntime(context) {
return pageUiState.pageOptions; 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() { function readGlobalShowHeadingNumbers() {
return Boolean(currentPageOptions().showHeadingNumbers); 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 editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface'); var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
if (shell instanceof HTMLElement) { 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-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-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal')); shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default')); shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers)); shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
shell.style.width = '100%'; shell.style.width = '100%';
shell.style.maxWidth = options.wideLayout ? '980px' : '760px'; shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
} }
if (editorRoot instanceof HTMLElement) { if (editorRoot instanceof HTMLElement) {
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout))); 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)); editorSurface.setAttribute('data-show-heading-numbers', String(showHeadingNumbers));
} }
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout))); 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-page-small-text', String(Boolean(options.smallText)));
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal')); document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default')); document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
@@ -235,11 +351,58 @@ export function createSidebarPageSettingsRuntime(context) {
'</label>'; '</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) { function renderGlobalOptions(popover) {
var globalHeadingNumbers = readGlobalShowHeadingNumbers(); var globalHeadingNumbers = readGlobalShowHeadingNumbers();
popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) { popover.querySelectorAll('[data-global-option-checkbox="showHeadingNumbers"]').forEach(function(input) {
input.checked = globalHeadingNumbers; 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() { function createPageFontRow() {
@@ -410,6 +573,7 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>' + '</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' + '<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
createGlobalHeadingNumbersRow() + createGlobalHeadingNumbersRow() +
createPageWidthRows() +
'</div>' + '</div>' +
'<div class="wolai-page-settings-actions">' + '<div class="wolai-page-settings-actions">' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">页面历史...</button>' + '<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() { function isPageSettingsOpen() {
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]'); var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden; return popover instanceof HTMLElement && !popover.hidden;
@@ -684,6 +913,10 @@ export function createSidebarPageSettingsRuntime(context) {
else openPageSettingsPopover(); else openPageSettingsPopover();
} }
window.addEventListener('mnote:active-resource-tab-changed', function() {
applyPageOptionsToShell();
});
return { return {
applyPageOptionsToShell, applyPageOptionsToShell,
closePageHistoryDrawer, closePageHistoryDrawer,
@@ -700,11 +933,13 @@ export function createSidebarPageSettingsRuntime(context) {
openPageShareDialog, openPageShareDialog,
pageOptionIsSupported, pageOptionIsSupported,
persistPageOptionsPatch, persistPageOptionsPatch,
persistPageWidthPreference,
recordPageHistorySnapshot, recordPageHistorySnapshot,
renderPageSettingsPopover, renderPageSettingsPopover,
setActivePageSettingsTab, setActivePageSettingsTab,
togglePageSettingsPopover, togglePageSettingsPopover,
updatePageSettingsTriggerState, updatePageSettingsTriggerState,
writeGlobalShowHeadingNumbers, writeGlobalShowHeadingNumbers,
loadPageWidthPreferences,
}; };
} }
@@ -67,7 +67,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => {
window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
documentId: nodeId, documentId: nodeId,
workspaceId: workspaceId || '', workspaceId: workspaceId || '',
sourceKind: targetUrl.searchParams.get('sourceKind') || 'convex_workspace', sourceKind: targetUrl.searchParams.get('sourceKind') || 'local_folder',
rootUri: targetUrl.searchParams.get('rootUri') || '', rootUri: targetUrl.searchParams.get('rootUri') || '',
url: targetUrl, url: targetUrl,
}).then(function(){ }).then(function(){
@@ -418,11 +418,32 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
function applyLocalFolderWatchBatch(payload) { function applyLocalFolderWatchBatch(payload) {
var batch = payload && (payload.payload || 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) var affectedParents = Array.isArray(batch && batch.affectedParents)
? batch.affectedParents ? batch.affectedParents
: Array.isArray(batch && batch.affected_parents) : Array.isArray(batch && batch.affected_parents)
? 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) { if (!affectedParents.length) {
setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents'); setTreeLiveApplyError('local_folder_watch_batch_missing_affected_parents');
return false; return false;
@@ -663,7 +684,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return { return {
userId: currentActorStorageId(), userId: currentActorStorageId(),
workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'), 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, treeKind: normalizedTreeKind,
rootUri: rootUri, rootUri: rootUri,
scope: scope scope: scope
@@ -685,7 +706,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
return SIDEBAR_TREE_VIEW_STATE_KEY return SIDEBAR_TREE_VIEW_STATE_KEY
.replace('{userId}', encodeURIComponent(scope.userId || 'anonymous')) .replace('{userId}', encodeURIComponent(scope.userId || 'anonymous'))
.replace('{workspaceId}', encodeURIComponent(scope.workspaceId || 'default')) .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('{treeKind}', encodeURIComponent(scope.treeKind || 'filetree'))
.replace('{rootUriHash}', stableTreeViewStateHash(scope.rootUri || '')) .replace('{rootUriHash}', stableTreeViewStateHash(scope.rootUri || ''))
.replace('{scopeHash}', stableTreeViewStateHash(scope.scope || 'root')); .replace('{scopeHash}', stableTreeViewStateHash(scope.scope || 'root'));
@@ -29,6 +29,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var activeTreeContextMenu = null; var activeTreeContextMenu = null;
var pageUiState = { var pageUiState = {
pageOptions: null, pageOptions: null,
pageWidthPreferences: null,
historySnapshots: [], historySnapshots: [],
pageSettingsOpen: false, pageSettingsOpen: false,
pageAiOpen: false, pageAiOpen: false,
@@ -244,12 +245,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args); const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args); const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args); const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args); const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args); const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args); const setActivePageSettingsTab = (...args) => sidebarPageSettings.setActivePageSettingsTab(...args);
const togglePageSettingsPopover = (...args) => sidebarPageSettings.togglePageSettingsPopover(...args); const togglePageSettingsPopover = (...args) => sidebarPageSettings.togglePageSettingsPopover(...args);
const updatePageSettingsTriggerState = (...args) => sidebarPageSettings.updatePageSettingsTriggerState(...args); const updatePageSettingsTriggerState = (...args) => sidebarPageSettings.updatePageSettingsTriggerState(...args);
const writeGlobalShowHeadingNumbers = (...args) => sidebarPageSettings.writeGlobalShowHeadingNumbers(...args); const writeGlobalShowHeadingNumbers = (...args) => sidebarPageSettings.writeGlobalShowHeadingNumbers(...args);
const loadPageWidthPreferences = (...args) => sidebarPageSettings.loadPageWidthPreferences(...args);
function setCommandPending(trigger, pending) { function setCommandPending(trigger, pending) {
if (!(trigger instanceof HTMLElement)) return; 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() : ''; 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() { function currentTopbarTitle() {
var title = document.querySelector('[data-page-title-current="true"]'); var title = document.querySelector('[data-page-title-current="true"]');
return title && title.textContent ? title.textContent.trim() : '无标题'; 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); pageRoot.setAttribute('data-mnote-page-tree-scope', relativePath);
} }
persistStarredFolderScope(workspaceId, rootUri, 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); document.documentElement.setAttribute('data-mnote-filetree-scope', relativePath);
var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin); var sidebarUrl = new URL('/api/tree/projections/sidebar', window.location.origin);
sidebarUrl.searchParams.set('workspaceId', workspaceId); sidebarUrl.searchParams.set('workspaceId', workspaceId);
@@ -805,10 +867,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
copyWorkspaceSourceParams, copyWorkspaceSourceParams,
currentDocumentId, currentDocumentId,
currentRootUri, currentRootUri,
currentSourceKind,
fileTreeIconKindForFileName: (...args) => fileTreeIconKindForFileName(...args), fileTreeIconKindForFileName: (...args) => fileTreeIconKindForFileName(...args),
getNavigationInFlight: () => mnoteNavigationInFlight, getNavigationInFlight: () => mnoteNavigationInFlight,
isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args), isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
isNonOfficeAttachmentName: (...args) => isNonOfficeAttachmentName(...args), isNonOfficeAttachmentName: (...args) => isNonOfficeAttachmentName(...args),
isPdfAttachmentFileName: (...args) => isPdfAttachmentFileName(...args),
openCodeEditorAttachment: (...args) => openCodeEditorAttachment(...args), openCodeEditorAttachment: (...args) => openCodeEditorAttachment(...args),
resolveWorkspaceId, resolveWorkspaceId,
setNavigationInFlight: (value) => { mnoteNavigationInFlight = String(value || ''); }, setNavigationInFlight: (value) => { mnoteNavigationInFlight = String(value || ''); },
@@ -818,6 +882,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const inferOnlyOfficeFileType = (...args) => sidebarFileTreeOpen.inferOnlyOfficeFileType(...args); const inferOnlyOfficeFileType = (...args) => sidebarFileTreeOpen.inferOnlyOfficeFileType(...args);
const buildOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenUrl(...args); const buildOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenUrl(...args);
const buildOnlyOfficeOpenPath = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenPath(...args); const buildOnlyOfficeOpenPath = (...args) => sidebarFileTreeOpen.buildOnlyOfficeOpenPath(...args);
const buildPdfPreviewOpenUrl = (...args) => sidebarFileTreeOpen.buildPdfPreviewOpenUrl(...args);
const buildLocalOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildLocalOnlyOfficeOpenUrl(...args); const buildLocalOnlyOfficeOpenUrl = (...args) => sidebarFileTreeOpen.buildLocalOnlyOfficeOpenUrl(...args);
const openLocalOfficeFileInActiveTab = (...args) => sidebarFileTreeOpen.openLocalOfficeFileInActiveTab(...args); const openLocalOfficeFileInActiveTab = (...args) => sidebarFileTreeOpen.openLocalOfficeFileInActiveTab(...args);
const buildMindmapOpenPath = (...args) => sidebarFileTreeOpen.buildMindmapOpenPath(...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 === 'md' || ext === 'markdown') return 'markdown';
if (ext === 'pdf') return 'pdf'; if (ext === 'pdf') return 'pdf';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'].indexOf(ext) >= 0) return 'image'; 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 ''; 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-inserted', 'false');
document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_content_failed'); 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 (targetRoot instanceof HTMLElement) window.__mnoteLastEditorUploadRoot = targetRoot;
enhanceEditorAttachmentLinks(); enhanceEditorAttachmentLinks();
var selector = assetId var selector = assetId
@@ -1729,12 +1797,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var link = targetRoot instanceof HTMLElement var link = targetRoot instanceof HTMLElement
? targetRoot.querySelector(selector) ? targetRoot.querySelector(selector)
: document.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) { if (link instanceof HTMLElement) {
link.setAttribute('data-mnote-attachment-link', 'true'); link.setAttribute('data-mnote-attachment-link', 'true');
if (assetId) link.setAttribute('data-asset-id', assetId); if (assetId) link.setAttribute('data-asset-id', assetId);
if (sizeLabel) link.setAttribute('data-file-size', sizeLabel); 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; return inserted;
} }
} catch (error) { } catch (error) {
@@ -1801,7 +1879,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (options && options.insertIntoEditor) { if (options && options.insertIntoEditor) {
await insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options)); await insertUploadedAssetIntoEditor(localPayload.asset, editorRootFromUploadOptions(options));
} }
void refreshLocalFolderSidebarSnapshot(); if (!(options && options.insertIntoEditor)) void refreshLocalFolderSidebarSnapshot();
window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', { window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', {
detail: { docId: documentId, asset: localPayload.asset, assetIds: [localPayload.asset.id] } 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) { window.addEventListener('wolai:assets-changed', function(event) {
applyAssetsChangedToFileTree(event.detail || {}); applyAssetsChangedToFileTree(event.detail || {});
}); });
window.addEventListener('wolai:local-assets-changed', function(event) {
applyAssetsChangedToFileTree(event.detail || {});
});
installMindmapAssetFetchObserver(); installMindmapAssetFetchObserver();
async function uploadFilesWithResolvedTarget(files, detail, options) { async function uploadFilesWithResolvedTarget(files, detail, options) {
@@ -2022,6 +2103,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const decodeLocalEncodedPath = (...args) => sidebarFileTreeCommand.decodeLocalEncodedPath(...args); const decodeLocalEncodedPath = (...args) => sidebarFileTreeCommand.decodeLocalEncodedPath(...args);
const fileTreeRowLocalRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalRelativePath(...args); const fileTreeRowLocalRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalRelativePath(...args);
const fileTreeRowLocalUploadTargetRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalUploadTargetRelativePath(...args); const fileTreeRowLocalUploadTargetRelativePath = (...args) => sidebarFileTreeCommand.fileTreeRowLocalUploadTargetRelativePath(...args);
const readWorkspacePathFromRow = (...args) => sidebarFileTreeCommand.readWorkspacePathFromRow(...args);
const fileTreeRowKind = (...args) => sidebarFileTreeCommand.fileTreeRowKind(...args); const fileTreeRowKind = (...args) => sidebarFileTreeCommand.fileTreeRowKind(...args);
const isFileTreeDownloadableAssetRow = (...args) => sidebarFileTreeCommand.isFileTreeDownloadableAssetRow(...args); const isFileTreeDownloadableAssetRow = (...args) => sidebarFileTreeCommand.isFileTreeDownloadableAssetRow(...args);
const isFileTreeDownloadableRow = (...args) => sidebarFileTreeCommand.isFileTreeDownloadableRow(...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 pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args); const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...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 updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({ const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
@@ -2308,12 +2392,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
buildLocalOnlyOfficeOpenUrl, buildLocalOnlyOfficeOpenUrl,
buildOnlyOfficeOpenPath, buildOnlyOfficeOpenPath,
buildOnlyOfficeOpenUrl, buildOnlyOfficeOpenUrl,
buildPdfPreviewOpenUrl,
closestAction, closestAction,
currentDocumentId, currentDocumentId,
currentRootUri, currentRootUri,
currentWorkspaceSourcePayload, currentWorkspaceSourcePayload,
fileTreeIconKindForFileName, fileTreeIconKindForFileName,
healLegacyOfficeAttachmentParagraphs, healLegacyOfficeAttachmentParagraphs,
hydrateEditorAttachmentMeta,
inferCodeAttachmentLanguage: (...args) => inferCodeAttachmentLanguage(...args), inferCodeAttachmentLanguage: (...args) => inferCodeAttachmentLanguage(...args),
inferOnlyOfficeFileType, inferOnlyOfficeFileType,
isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args), isCodeAttachmentFileName: (...args) => isCodeAttachmentFileName(...args),
@@ -2366,7 +2452,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return; 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) { if (editorAttachmentLink instanceof HTMLAnchorElement) {
e.preventDefault(); e.preventDefault();
openEditorAttachmentLink(editorAttachmentLink); openEditorAttachmentLink(editorAttachmentLink);
@@ -2503,6 +2589,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return; 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]'); var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
if (pageAiMemorySave) { if (pageAiMemorySave) {
e.preventDefault(); e.preventDefault();
@@ -2820,14 +2920,24 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
e.preventDefault(); e.preventDefault();
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey }); selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey });
var objectIdentity = readFileTreeObjectIdentity(fileRow); 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) { if (e.shiftKey || e.ctrlKey || e.metaKey) {
return; return;
} }
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && documentId) { 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() }); navigateToDocument(documentId, resolveWorkspaceId(fileRow), { treeView: 'filetree', fileTreeScope: currentFileTreeScope() });
} else if (assetId) { } 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; return;
} }
@@ -2859,7 +2969,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}); });
document.addEventListener('contextmenu', function(event) { 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) { if (editorAttachmentLink instanceof HTMLAnchorElement) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -2904,6 +3014,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
// inline fallback // inline fallback
if (event.key === 'F2') { if (event.key === 'F2') {
event.preventDefault(); event.preventDefault();
var renameCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')) {
return;
}
beginFileTreeInlineRename(fileTreeRowForKey); beginFileTreeInlineRename(fileTreeRowForKey);
return; return;
} }
@@ -2921,14 +3035,18 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
} }
if (shortcutKey === 'v') { if (shortcutKey === 'v') {
event.preventDefault(); event.preventDefault();
var pasteCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(pasteCtx, '!workspace.readonly')) {
return;
}
void pasteSidebarFileTreeClipboard(fileTreeRowForKey); void pasteSidebarFileTreeClipboard(fileTreeRowForKey);
return; return;
} }
} }
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault(); event.preventDefault();
var delCtx = buildSidebarFileTreeContext('filetree'); var delCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey });
if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) { if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')) {
return; return;
} }
void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey); void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey);
@@ -3044,12 +3162,20 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (Object.keys(next).length) { if (Object.keys(next).length) {
void persistPageOptionsPatch(next); 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() { function initializePageUiSurfaces() {
pageUiState.pageOptions = null; pageUiState.pageOptions = null;
pageUiState.pageWidthPreferences = null;
applyPageOptionsToShell(); applyPageOptionsToShell();
void loadPageWidthPreferences();
updatePageSettingsTriggerState(); updatePageSettingsTriggerState();
updatePageAiTriggerState(); updatePageAiTriggerState();
ensureHistorySnapshotsSeeded(); ensureHistorySnapshotsSeeded();
@@ -103,7 +103,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
) { ) {
return 'local_folder'; return 'local_folder';
} }
return 'convex_workspace'; return 'local_folder';
} }
function currentRootUri() { function currentRootUri() {
@@ -2,7 +2,7 @@
var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__'); var script = document.getElementById('__MNOTE_TREE_LIVE_BOOTSTRAP__');
var fallback = { var fallback = {
schema: 'mnote.tree_live_bootstrap.v1', schema: 'mnote.tree_live_bootstrap.v1',
transport: 'convex-command-log-sse', transport: 'tree-live-sse',
endpoint: '/api/tree/events', endpoint: '/api/tree/events',
rootIds: [], rootIds: [],
initialRevision: null initialRevision: null
@@ -34,7 +34,7 @@
} }
function applyTransport(transport) { 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() { function closeActiveSource() {
@@ -169,7 +169,7 @@
} }
function startWithSseFallback(bootstrap, workspaceId) { 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); var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId); url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) { if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
@@ -208,9 +208,13 @@
var workspaceId = bootstrap.workspaceId || resolveWorkspaceId(); var workspaceId = bootstrap.workspaceId || resolveWorkspaceId();
// Prefer WebSocket transport when available // 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) { if (preferWs) {
applyTransport('convex-command-log-ws'); applyTransport('tree-live-ws');
startWithWebSocket(bootstrap, workspaceId, bootstrap.wsEndpoint || '/api/realtime/ws'); startWithWebSocket(bootstrap, workspaceId, bootstrap.wsEndpoint || '/api/realtime/ws');
return; return;
} }
@@ -220,7 +224,7 @@
applyStatus('unsupported'); applyStatus('unsupported');
return; 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); var url = new URL(bootstrap.endpoint || '/api/tree/events', window.location.origin);
url.searchParams.set('workspaceId', workspaceId); url.searchParams.set('workspaceId', workspaceId);
if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) { if (Array.isArray(bootstrap.rootIds) && bootstrap.rootIds.length === 1) {
@@ -303,7 +303,7 @@ export function createTreeShellFileTreeDndRuntime(context) {
`目标: ${targetLabel}`, `目标: ${targetLabel}`,
context.sourceKind === "local_folder" context.sourceKind === "local_folder"
? "目标可写时复制进本地文件夹;命名冲突使用递增命名。" ? "目标可写时复制进本地文件夹;命名冲突使用递增命名。"
: "Convex 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。", : "legacy cloud 目标交给宿主上传到对象存储;命名冲突由上传 executor 处理。",
], ],
}; };
} }
@@ -63,11 +63,11 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
}), }),
createFileTreeMenuItem("newFolder", "新建文件夹", { createFileTreeMenuItem("newFolder", "新建文件夹", {
disabled: !canCreateFolder, disabled: !canCreateFolder,
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
}), }),
createFileTreeMenuItem("upload", "上传/导入", { createFileTreeMenuItem("upload", "上传/导入", {
disabled: true, disabled: true,
reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入", reason: localSource ? "外部文件请拖入 Explorer" : "legacy cloud 上传 executor 尚未接入",
}), }),
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }), createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
createFileTreeMenuItem("collapseAll", "全部折叠"), createFileTreeMenuItem("collapseAll", "全部折叠"),
@@ -95,7 +95,7 @@ export function buildTreeShellFileTreeContextMenuProfile(context, target) {
})); }));
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", { items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
disabled: !canCreateFolder, disabled: !canCreateFolder,
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹", reason: convexSource ? "legacy cloud workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
})); }));
items.push(createFileTreeMenuItem("paste", "粘贴", { items.push(createFileTreeMenuItem("paste", "粘贴", {
disabled: !canPaste, disabled: !canPaste,
@@ -133,13 +133,20 @@ export function getFileTreeRowIconKind(item) {
if (iconHint === "table") return "table"; if (iconHint === "table") return "table";
if (iconHint === "index") return "index"; if (iconHint === "index") return "index";
if (iconHint === "page") return "page"; 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 === "document") return "page";
if (item.rowKind === "folder") return "folder"; 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 === "index") return "index";
if (item.rowKind === "asset_folder") return "mindmap"; if (item.rowKind === "asset_folder") return "mindmap";
if (item.resourceMeta?.resourceKind === "table") return "table"; if (item.resourceMeta?.resourceKind === "table") return "table";
if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap"; if (item.resourceMeta?.resourceKind === "mindmap") return "mindmap";
if (item.resourceMeta?.resourceKind === "office") return "office";
if (item.resourceMeta?.resourceKind === "pdf") return "pdf";
return "file"; return "file";
} }
@@ -108,7 +108,7 @@ function startTreeShellRuntime() {
const sourceKind = const sourceKind =
typeof state.sourceKind === "string" && state.sourceKind.trim() typeof state.sourceKind === "string" && state.sourceKind.trim()
? state.sourceKind.trim() ? state.sourceKind.trim()
: "convex_workspace"; : "local_folder";
const rootUri = const rootUri =
typeof state.rootUri === "string" && state.rootUri.trim() typeof state.rootUri === "string" && state.rootUri.trim()
? state.rootUri.trim() ? state.rootUri.trim()
+2 -12
View File
@@ -58,18 +58,8 @@ impl AppConfig {
.unwrap_or_else(|_| "/api/hermes".into()), .unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH") compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()), .unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: env::var("CONVEX_SELF_HOSTED_URL") convex_url: None,
.ok() convex_admin_key: None,
.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()),
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES") allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
.ok() .ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
@@ -71,6 +71,12 @@ struct BufferStoreInner {
buffers: HashMap<BufferKey, DocumentBuffer>, buffers: HashMap<BufferKey, DocumentBuffer>,
} }
#[derive(Debug, Clone)]
pub struct BufferExternalModificationOutcome {
pub buffer: DocumentBuffer,
pub self_write_echo: bool,
}
impl BufferStore { impl BufferStore {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -102,6 +108,8 @@ impl BufferStore {
last_loaded_at: Some(now_ms), last_loaded_at: Some(now_ms),
last_saved_at: Some(now_ms), last_saved_at: Some(now_ms),
external_actor: None, external_actor: None,
last_write_intent_id: None,
last_save_operation_id: None,
}; };
inner.buffers.insert(key, buf.clone()); inner.buffers.insert(key, buf.clone());
buf buf
@@ -131,11 +139,27 @@ impl BufferStore {
path: &ObjectWorkspacePath, path: &ObjectWorkspacePath,
file_version: String, file_version: String,
content_hash: 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> { ) -> Option<DocumentBuffer> {
let key = BufferKey::from_workspace_path(path); let key = BufferKey::from_workspace_path(path);
let mut inner = self.inner.write().expect("BufferStore lock"); let mut inner = self.inner.write().expect("BufferStore lock");
if let Some(buf) = inner.buffers.get_mut(&key) { 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()) Some(buf.clone())
} else { } else {
None 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(编辑器内容已修改)。 /// 标记 buffer 为 dirty(编辑器内容已修改)。
pub fn mark_dirty( pub fn mark_dirty(
&self, &self,
@@ -348,6 +420,8 @@ mod tests {
last_loaded_at: Some(1_700_000_000_000i64), last_loaded_at: Some(1_700_000_000_000i64),
last_saved_at: Some(1_700_000_000_001i64), last_saved_at: Some(1_700_000_000_001i64),
external_actor: None, 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] #[test]
fn document_buffer_mark_saved_after_page_body_write() { fn document_buffer_mark_saved_after_page_body_write() {
let store = BufferStore::new(); let store = BufferStore::new();
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::hermes_tools::ToolCallInput; 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 crate::routes::ensure_local_workspace_access;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
@@ -225,7 +225,7 @@ async fn create_artifact_node(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
context, context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -6,7 +6,7 @@ use crate::hermes_tools::doc::{
required_arg, required_arg,
}; };
use crate::hermes_tools::ToolCallInput; 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::{ use bridge_runtime::{
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire, apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire, RuntimeSourceWire, RuntimeTargetWire,
@@ -1147,7 +1147,7 @@ async fn execute_page_body_save(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
context, context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -1221,7 +1221,7 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
context, context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -1703,6 +1703,8 @@ pub async fn doc_markdown_edit(
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(), root_uri: root_uri.to_string(),
expected_file_version, expected_file_version,
write_intent_id: Some("intent:ai:mnote.doc.markdown_edit".into()),
save_operation_id: None,
base_content_hash: None, base_content_hash: None,
content_format: "editorBlocks".into(), content_format: "editorBlocks".into(),
content: next_content, content: next_content,
@@ -1774,7 +1776,7 @@ pub async fn doc_markdown_edit(
validate_only: false, validate_only: false,
}; };
let execution = 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, state,
context, context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::hermes_tools::ToolCallInput; 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 crate::routes::web_shell::build_page_aggregate_snapshot;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
@@ -211,6 +211,8 @@ async fn page_command(
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.clone(), root_uri: root_uri.clone(),
expected_file_version: input.arg_string("expectedFileVersion"), 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"), base_content_hash: input.arg_string("baseContentHash"),
content_format: "editorBlocks".into(), content_format: "editorBlocks".into(),
content, content,
@@ -304,7 +306,7 @@ async fn page_command(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
context, context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -1,8 +1,11 @@
use crate::document_buffer_store::BufferStore; 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::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
@@ -212,6 +215,12 @@ fn spawn_local_folder_watcher(
&root_uri_for_task, &root_uri_for_task,
&relative_path, &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 = if is_markdown_path(&path) {
let document_id = let document_id =
format!("local-md:{}", encode_local_id_segment(&relative_path)); format!("local-md:{}", encode_local_id_segment(&relative_path));
@@ -226,10 +235,40 @@ fn spawn_local_folder_watcher(
&relative_path, &relative_path,
&document_id, &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, &ws_path,
observed_file_version.clone(),
Some("external-editor".into()), 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 document_id
} else { } else {
@@ -241,8 +280,13 @@ fn spawn_local_folder_watcher(
"rootUri": root_uri_for_task, "rootUri": root_uri_for_task,
"relativePath": relative_path, "relativePath": relative_path,
"documentId": document_id, "documentId": document_id,
"eventKind": format!("{:?}", event.kind), "eventKind": event_kind,
"revision": event_revision(&path), "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); let _ = sender_for_task.send(payload);
} }
@@ -375,12 +419,12 @@ fn system_time_ms(time: SystemTime) -> u128 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind, LocalFolderWatcherRegistry, is_local_search_index_path,
LocalFolderWatcherRegistry, refresh_local_search_index_for_event, should_emit_event_kind,
}; };
use crate::document_buffer_store::BufferStore; use crate::document_buffer_store::BufferStore;
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
use notify::EventKind; use notify::EventKind;
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
fn test_root(name: &str) -> std::path::PathBuf { fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!( 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::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ 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::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -79,7 +79,7 @@ async fn execute_bridge_query(
workspace_id: &str, workspace_id: &str,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> 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( pub async fn workspace(
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -2,15 +2,16 @@ use crate::app::{AppConfig, AppState};
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::transport::convex::{ 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::{ use bridge_runtime::{
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire, RuntimeTargetWire, execute_runtime_input,
}; };
use serde_json::json;
use serde_json::Value; use serde_json::Value;
use serde_json::json;
pub fn runtime_context( pub fn runtime_context(
context: &RequestContext, context: &RequestContext,
@@ -63,22 +64,22 @@ pub fn build_runtime_command_plan(
Ok(plan) Ok(plan)
} }
pub async fn execute_runtime_command_via_convex( pub async fn execute_runtime_command_via_legacy_cloud(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
command: RuntimeCommandEnvelopeWire, command: RuntimeCommandEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?; 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, state: &AppState,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
command: RuntimeCommandEnvelopeWire, command: RuntimeCommandEnvelopeWire,
) -> Result<ConvexCommandExecution, WebError> { ) -> Result<RetiredCloudCommandExecution, WebError> {
let runtime_context = runtime_context(context, effective_workspace_id); let runtime_context = runtime_context(context, effective_workspace_id);
let runtime_input = RuntimeInput::Command { let runtime_input = RuntimeInput::Command {
context: runtime_context.clone(), 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)); 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(), state.config(),
context, context,
&runtime_context, &runtime_context,
+1 -1
View File
@@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
+1 -1
View File
@@ -1,6 +1,6 @@
use axum::Json;
use axum::http::{HeaderMap, HeaderValue}; use axum::http::{HeaderMap, HeaderValue};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize; use serde::Serialize;
use std::sync::LazyLock; use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH}; 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::document_buffer_store::{self, BufferKey, BufferStore};
use crate::error::WebError; use crate::error::WebError;
use crate::routes::command_support::{ 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::{ use crate::routes::local_folder_source::{
ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body, ensure_local_workspace_access, update_local_markdown_title, write_local_markdown_page_body,
}; };
use crate::routes::query_support::{ 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, resolve_effective_workspace_id,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::fs; use std::fs;
use std::time::Duration; use std::time::Duration;
@@ -48,6 +49,17 @@ pub struct BufferStateQuery {
pub relative_path: Option<String>, 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( fn find_document_buffer_state(
buffer_store: &BufferStore, buffer_store: &BufferStore,
query: &BufferStateQuery, query: &BufferStateQuery,
@@ -104,6 +116,20 @@ fn find_document_buffer_state(
None 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)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest { pub struct DocumentSaveRequest {
@@ -114,6 +140,8 @@ pub struct DocumentSaveRequest {
pub revision: Option<u64>, pub revision: Option<u64>,
pub conflict_detection_key: Option<String>, pub conflict_detection_key: Option<String>,
pub expected_file_version: 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 base_content_hash: Option<String>,
pub content_format: Option<String>, pub content_format: Option<String>,
pub editor_source: 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 execution
.artifacts .artifacts
.as_ref() .as_ref()
@@ -480,6 +510,8 @@ async fn proxy_next_documents_save(
"revision": body.revision, "revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key, "conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version, "expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
"editorDocument": body.editor_document, "editorDocument": body.editor_document,
"content": body.content, "content": body.content,
"tiptapDocument": body.tiptap_document, "tiptapDocument": body.tiptap_document,
@@ -522,7 +554,7 @@ pub async fn load_document_meta_result(
) )
.await; .await;
} }
fetch_documents_meta_via_convex( fetch_documents_meta_via_legacy_cloud(
state.config(), state.config(),
context, context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -553,7 +585,7 @@ pub async fn load_document_content_result(
) )
.await; .await;
} }
execute_runtime_query_via_convex( execute_runtime_query_via_legacy_cloud(
state.config(), state.config(),
context, context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -599,20 +631,7 @@ pub async fn buffer_state(
let result = find_document_buffer_state(&state.buffer_store, &query); let result = find_document_buffer_state(&state.buffer_store, &query);
match result { match result {
Some(buf) => Ok(ok_response( Some(buf) => Ok(ok_response(&context, buffer_state_payload(buf))),
&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,
}),
)),
None => Err(WebError::new( None => Err(WebError::new(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"buffer_state_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( pub async fn page_body_write(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -690,6 +754,8 @@ pub async fn save(
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.to_string(), root_uri: root_uri.to_string(),
expected_file_version: expected_file_version.map(ToOwned::to_owned), 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(), base_content_hash: body.base_content_hash.clone(),
content_format: body content_format: body
.content_format .content_format
@@ -740,6 +806,8 @@ pub async fn save(
"revision": body.revision, "revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key, "conflictDetectionKey": body.conflict_detection_key,
"expectedFileVersion": body.expected_file_version, "expectedFileVersion": body.expected_file_version,
"writeIntentId": body.write_intent_id,
"saveOperationId": body.save_operation_id,
"editorDocument": body.editor_document, "editorDocument": body.editor_document,
"content": body.content, "content": body.content,
"tiptapDocument": body.tiptap_document, "tiptapDocument": body.tiptap_document,
@@ -752,7 +820,7 @@ pub async fn save(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -815,7 +883,7 @@ pub async fn purge(
validate_only: false, validate_only: false,
}; };
let result = 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)) Ok(ok_response(&context, result))
} }
@@ -865,7 +933,7 @@ pub async fn empty_trash(
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
Some(workspace_id), Some(workspace_id),
@@ -968,13 +1036,14 @@ pub async fn title(
}), }),
preflight_data: None, preflight_data: None,
reason: Some("mnote-web page title update".into()), reason: Some("mnote-web page title update".into()),
refs: vec![body refs: vec![
.command_name body.command_name
.unwrap_or_else(|| "page.head.updateTitle".into())], .unwrap_or_else(|| "page.head.updateTitle".into()),
],
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -1072,13 +1141,14 @@ pub async fn options(
}), }),
preflight_data: None, preflight_data: None,
reason: Some("mnote-web page layout update".into()), reason: Some("mnote-web page layout update".into()),
refs: vec![body refs: vec![
.command_name body.command_name
.unwrap_or_else(|| "page.layout.updateOptions".into())], .unwrap_or_else(|| "page.layout.updateOptions".into()),
],
dry_run: false, dry_run: false,
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -1111,9 +1181,9 @@ pub async fn options(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore}; use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -1445,8 +1515,7 @@ mod tests {
"upsert_document" "upsert_document"
); );
assert_eq!( assert_eq!(
payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"] payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"],
["title"],
"服务端页面(改名)" "服务端页面(改名)"
); );
assert_eq!(payload["meta"]["artifactError"], Value::Null); assert_eq!(payload["meta"]["artifactError"], Value::Null);
@@ -1727,15 +1796,19 @@ mod tests {
payload["details"]["conflict"]["editorBaseVersion"].as_str(), payload["details"]["conflict"]["editorBaseVersion"].as_str(),
Some(stale_file_version.as_str()) Some(stale_file_version.as_str())
); );
assert!(payload["details"]["conflict"]["currentDiskVersion"] assert!(
payload["details"]["conflict"]["currentDiskVersion"]
.as_str() .as_str()
.unwrap_or("") .unwrap_or("")
.starts_with("local-md:local-md:README.md:")); .starts_with("local-md:local-md:README.md:")
assert!(payload["details"]["conflict"]["suggestedActions"] );
assert!(
payload["details"]["conflict"]["suggestedActions"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .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"); let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(markdown.contains("# External")); assert!(markdown.contains("# External"));
assert!(!markdown.contains("# Editor")); assert!(!markdown.contains("# Editor"));
+6 -6
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::documents::{ 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::extract::{Extension, Json, Query, State};
use axum::http::{header, HeaderValue, StatusCode}; use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
+659 -63
View File
@@ -4,6 +4,7 @@ use crate::error::WebError;
use crate::routes::local_folder_source::{ use crate::routes::local_folder_source::{
control_plane_db_path_display, create_default_local_workspace_for_actor, control_plane_db_path_display, create_default_local_workspace_for_actor,
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, 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_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
load_local_trash_entries, 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_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot, 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::{ 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::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State}; 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 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::{ use control_plane::{
session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput,
UpsertUserInput, NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash,
}; };
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use leptos::prelude::InnerHtmlAttribute; use leptos::prelude::InnerHtmlAttribute;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::time::Duration; use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
@@ -73,6 +74,8 @@ pub(crate) struct RootEntryQuery {
file_tree_scope: Option<String>, file_tree_scope: Option<String>,
tree_view: Option<String>, tree_view: Option<String>,
restore_focus_row_id: 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 { 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( pub async fn root_entry(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
uri: Uri,
Query(query): Query<RootEntryQuery>, Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) { if !has_real_auth_context(&state, &context) {
let mut response = Response::builder() let mut response = Response::builder()
.status(StatusCode::SEE_OTHER) .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()) .body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?; .map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false); stamp_gateway_headers(response.headers_mut(), false);
@@ -350,21 +364,16 @@ pub async fn root_entry(
&workspace_id, &workspace_id,
&mut workspace_dataset, &mut workspace_dataset,
); );
let workspace_projection = build_workspace_shell_projection( let mut workspace_projection = build_workspace_shell_projection(
&workspace_dataset, &workspace_dataset,
&workspace_id, &workspace_id,
requested_or_recent_page_id.as_deref(), requested_or_recent_page_id.as_deref(),
"本地文件夹", "本地文件夹",
); );
let selected_active_page_id = choose_root_entry_active_page_id( let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
requested_page_id.clone(), if selected_active_page_id.is_none() {
None, apply_active_page(&mut workspace_projection, None);
workspace_projection.active_page_id.as_deref(), }
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
&page_tree_snapshot, &page_tree_snapshot,
selected_active_page_id.as_deref(), selected_active_page_id.as_deref(),
@@ -419,21 +428,16 @@ pub async fn root_entry(
&workspace_id, &workspace_id,
&mut workspace_dataset, &mut workspace_dataset,
); );
let workspace_projection = build_workspace_shell_projection( let mut workspace_projection = build_workspace_shell_projection(
&workspace_dataset, &workspace_dataset,
&workspace_id, &workspace_id,
requested_page_id.as_deref(), requested_page_id.as_deref(),
&default_workspace_name, &default_workspace_name,
); );
let selected_active_page_id = choose_root_entry_active_page_id( let selected_active_page_id = requested_page_id.map(ToOwned::to_owned);
requested_page_id.clone(), if selected_active_page_id.is_none() {
recent_page_id.clone(), apply_active_page(&mut workspace_projection, None);
workspace_projection.active_page_id.as_deref(), }
workspace_projection
.my_page_items
.first()
.map(|item| item.id.as_str()),
);
let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot( let sidebar_tree_html = render_local_sidebar_tree_html_from_snapshot(
&snapshot, &snapshot,
selected_active_page_id.as_deref(), selected_active_page_id.as_deref(),
@@ -521,6 +525,30 @@ pub async fn root_entry(
.clone() .clone()
.unwrap_or_default(); .unwrap_or_default();
let show_admin_access_policy = is_local_access_policy_admin_context(&context); 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 = || { let render_workspace_entry = || {
crate::ssr::render_view(leptos::view! { crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::home::HomePage <crate::ssr::pages::home::HomePage
@@ -530,12 +558,12 @@ pub async fn root_entry(
workspace_sidebar_html={workspace_sidebar_html.clone()} workspace_sidebar_html={workspace_sidebar_html.clone()}
active_page_id={active_page_id.clone()} active_page_id={active_page_id.clone()}
active_page_title={active_page_title.clone()} active_page_title={active_page_title.clone()}
navigation_html={navigation_html.clone().unwrap_or_default()}
show_admin_access_policy={show_admin_access_policy} show_admin_access_policy={show_admin_access_policy}
/> />
}) })
}; };
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() { 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!({ let panes_bootstrap_json = serde_json::to_string(&json!({
"schema": "mnote.document_panes_bootstrap.v1", "schema": "mnote.document_panes_bootstrap.v1",
"secondaryRequested": false, "secondaryRequested": false,
@@ -543,15 +571,12 @@ pub async fn root_entry(
"panes": [], "panes": [],
})) }))
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
format!( let body_extra = format!(
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script> r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
{}"#, {}"#,
escape_script_json(&panes_bootstrap_json), escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(), render_editor_island_adapter_script(),
) );
} else {
String::new()
};
("MNOTE".to_string(), render_workspace_entry(), body_extra) ("MNOTE".to_string(), render_workspace_entry(), body_extra)
} else { } else {
match build_page_aggregate_snapshot( match build_page_aggregate_snapshot(
@@ -617,8 +642,7 @@ pub async fn root_entry(
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), 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() render_editor_runtime_preload_links()
} else { } else {
"" ""
@@ -651,6 +675,333 @@ pub async fn root_entry(
Ok(response) 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( pub async fn trash_entry(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -1606,7 +1957,7 @@ async fn resolve_root_workspace_id(
return Ok(workspace_id.to_string()); return Ok(workspace_id.to_string());
} }
let bootstrap = execute_convex_mutation_by_name( let bootstrap = execute_retired_mutation_by_name(
state.config(), state.config(),
context, context,
"workspaces:ensureDefaultWorkspace", "workspaces:ensureDefaultWorkspace",
@@ -2097,11 +2448,12 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use axum::response::{Html, IntoResponse}; use axum::response::{Html, IntoResponse};
use axum::routing::{get, post}; use axum::routing::{get, post};
use control_plane::{UpsertNavigationRecentInput, UpsertUserInput};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -2334,7 +2686,7 @@ mod tests {
false, false,
None, None,
Some( 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( .oneshot(
@@ -2367,7 +2719,7 @@ mod tests {
assert!(html.contains(r#"data-resource-kind="table""#)); assert!(html.contains(r#"data-resource-kind="table""#));
assert!(html.contains("已删附件.png")); assert!(html.contains("已删附件.png"));
assert!(html.contains("已删思维导图.json")); assert!(html.contains("已删思维导图.json"));
assert!(html.contains("已删表格.luckysheet")); assert!(html.contains("已删表格.table"));
assert!(html.contains("new EventSource")); assert!(html.contains("new EventSource"));
assert!(html.contains("/api/tree/events")); assert!(html.contains("/api/tree/events"));
assert!(!html.contains("pollMs")); assert!(!html.contains("pollMs"));
@@ -2476,6 +2828,30 @@ mod tests {
assert_eq!(empty, None); 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] #[tokio::test]
async fn root_entry_uses_recent_page_cookie_as_active_page() { async fn root_entry_uses_recent_page_cookie_as_active_page() {
let response = app_with_config("http://127.0.0.1:3100".into(), false) 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("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#)); 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-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-mnote-resource-tab-host="true""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); 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-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#)); assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace")); assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
@@ -2641,7 +3019,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() { 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 { let app_state = AppState::new(AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
@@ -2827,6 +3205,205 @@ mod tests {
assert!(!html.contains(r#"href="/tree"#)); 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] #[tokio::test]
async fn root_entry_local_folder_reuses_page_tree_snapshot_for_sidebar_html() { 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"); 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!(response.status(), StatusCode::OK);
assert_eq!( 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, 1,
"root entry should reuse the already loaded local page tree snapshot instead of scanning it twice", "root entry should reuse the already loaded local page tree snapshot instead of scanning it twice",
); );
@@ -2907,7 +3486,9 @@ mod tests {
.expect("body"); .expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8"); 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-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!( assert!(
!html.contains(r#"data-node-id="local-md:Home.md""#), !html.contains(r#"data-node-id="local-md:Home.md""#),
"星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树" "星标 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:folder:attachments""#));
assert!(!html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#)); 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-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-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(r#"data-pane-role="primary""#));
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__")); assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
assert!(html.contains( assert!(html.contains(
r#"<script type="module" src="/api/mnote-browser-runtime/document-editor-adapter-runtime.js"></script>"# 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">"# 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>"# r#"<link rel="preload" href="/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm" as="fetch" type="application/wasm" crossorigin>"#
)); ));
assert!( assert!(
@@ -2991,7 +3573,7 @@ mod tests {
.headers() .headers()
.get(header::LOCATION) .get(header::LOCATION)
.and_then(|value| value.to_str().ok()), .and_then(|value| value.to_str().ok()),
Some("/auth") Some("/auth?next=%2F")
); );
assert_eq!( assert_eq!(
response response
@@ -3090,12 +3672,14 @@ mod tests {
.and_then(|value| value.to_str().ok()), .and_then(|value| value.to_str().ok()),
Some("mnote-web") Some("mnote-web")
); );
assert!(response assert!(
response
.headers() .headers()
.get("content-type") .get("content-type")
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or_default() .unwrap_or_default()
.contains("text/html")); .contains("text/html")
);
} }
#[tokio::test] #[tokio::test]
@@ -3150,12 +3734,16 @@ mod tests {
.map(|value| value.to_str().unwrap_or_default()) .map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(values.iter().any(|value| value.contains("mnote_session="))); assert!(values.iter().any(|value| value.contains("mnote_session=")));
assert!(values assert!(
values
.iter() .iter()
.any(|value| value.contains("mnote_actor_id=new-user"))); .any(|value| value.contains("mnote_actor_id=new-user"))
assert!(values );
assert!(
values
.iter() .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) let body = to_bytes(response.into_body(), usize::MAX)
.await .await
.expect("body"); .expect("body");
@@ -3234,10 +3822,12 @@ mod tests {
.expect("body"); .expect("body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "auth_signup_email_required"); assert_eq!(payload["code"], "auth_signup_email_required");
assert!(payload["message"] assert!(
payload["message"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("注册账号时请填写邮箱")); .contains("注册账号时请填写邮箱")
);
} }
#[tokio::test] #[tokio::test]
@@ -3261,9 +3851,11 @@ mod tests {
.iter() .iter()
.map(|value| value.to_str().unwrap_or_default()) .map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(values assert!(
values
.iter() .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] #[tokio::test]
@@ -3369,12 +3961,16 @@ mod tests {
.iter() .iter()
.map(|value| value.to_str().unwrap_or_default()) .map(|value| value.to_str().unwrap_or_default())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(!values assert!(
!values
.iter() .iter()
.any(|value| value.contains("__convexAuthJWT="))); .any(|value| value.contains("__convexAuthJWT="))
assert!(!values );
assert!(
!values
.iter() .iter()
.any(|value| value.contains("__convexAuthRefreshToken="))); .any(|value| value.contains("__convexAuthRefreshToken="))
);
assert!(response.headers().get("x-mnote-legacy-upstream").is_none()); assert!(response.headers().get("x-mnote-legacy-upstream").is_none());
} }
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::Json; use axum::Json;
use axum::extract::{Extension, State};
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
+11 -9
View File
@@ -1,15 +1,15 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{ use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, RuntimeInput, build_failure_response, build_success_response, execute_runtime_input,
runtime_input_requests_result, RuntimeInput, execute_runtime_query, runtime_input_requests_result,
}; };
use serde::Serialize; 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_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner"; const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
@@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
@@ -253,10 +253,12 @@ mod tests {
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes"); assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes"); assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge"); assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(payload["eventStreamEndpoint"] assert!(
payload["eventStreamEndpoint"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("/api/hermes/events/")); .contains("/api/hermes/events/")
);
} }
#[tokio::test] #[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::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; 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::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json; use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::env; use std::env;
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
@@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::sync::Mutex; 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.fetch"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find"));
assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch"));
assert!(tools assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.doc.plan_update")); .any(|tool| tool["name"] == "mnote.doc.plan_update")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.block.replace")); .any(|tool| tool["name"] == "mnote.block.replace")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.block.insert_after")); .any(|tool| tool["name"] == "mnote.block.insert_after")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.block.delete")); .any(|tool| tool["name"] == "mnote.block.delete")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.block.move_after")); .any(|tool| tool["name"] == "mnote.block.move_after")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.doc.apply_block_ops")); .any(|tool| tool["name"] == "mnote.doc.apply_block_ops")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.mindmap.fetch")); .any(|tool| tool["name"] == "mnote.mindmap.fetch")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.mindmap.apply_ops")); .any(|tool| tool["name"] == "mnote.mindmap.apply_ops")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.office.fetch_summary")); .any(|tool| tool["name"] == "mnote.office.fetch_summary")
assert!(tools );
assert!(
tools
.iter() .iter()
.any(|tool| tool["name"] == "mnote.office.propose_changes")); .any(|tool| tool["name"] == "mnote.office.propose_changes")
);
let page_save = tools let page_save = tools
.iter() .iter()
.find(|tool| tool["name"] == "mnote.page.save") .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"]["operations"].is_object());
assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object()); assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object());
assert!(markdown_edit["inputSchema"]["anyOf"] assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array() .as_array()
.expect("anyOf") .expect("anyOf")
.iter() .iter()
.any(|rule| rule["required"] == json!(["operations"]))); .any(|rule| rule["required"] == json!(["operations"]))
assert!(markdown_edit["inputSchema"]["anyOf"] );
assert!(
markdown_edit["inputSchema"]["anyOf"]
.as_array() .as_array()
.expect("anyOf") .expect("anyOf")
.iter() .iter()
.any(|rule| rule["required"] == json!(["full_content"]))); .any(|rule| rule["required"] == json!(["full_content"]))
);
} }
#[tokio::test] #[tokio::test]
@@ -1228,14 +1252,18 @@ mod tests {
.find(|tool| tool["name"] == "mnote.page.save") .find(|tool| tool["name"] == "mnote.page.save")
.expect("page save tool"); .expect("page save tool");
assert!(markdown_edit["description"] assert!(
markdown_edit["description"]
.as_str() .as_str()
.expect("description") .expect("description")
.contains("兼容")); .contains("兼容")
assert!(markdown_edit["description"] );
assert!(
markdown_edit["description"]
.as_str() .as_str()
.expect("description") .expect("description")
.contains("agent 原生 patch/diff")); .contains("agent 原生 patch/diff")
);
assert_eq!( assert_eq!(
page_save["annotations"]["requiresWritePermission"], page_save["annotations"]["requiresWritePermission"],
Value::Bool(true) Value::Bool(true)
@@ -1426,10 +1454,12 @@ mod tests {
payload["result"]["objectIdentity"], payload["result"]["objectIdentity"],
"resource:mindmap:local-md:README.md:mind_allowed" "resource:mindmap:local-md:README.md:mind_allowed"
); );
assert!(payload["result"]["markdownSummary"] assert!(
payload["result"]["markdownSummary"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("中心主题")); .contains("中心主题")
);
assert_eq!(payload["result"]["nodes"][1]["text"], "分支一"); assert_eq!(payload["result"]["nodes"][1]["text"], "分支一");
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
@@ -1765,10 +1795,12 @@ mod tests {
assert_eq!(payload["toolName"], "mnote.page.get"); assert_eq!(payload["toolName"], "mnote.page.get");
assert_eq!(payload["toolCallId"], "call_1"); assert_eq!(payload["toolCallId"], "call_1");
assert_eq!(payload["result"]["title"], "服务端页面"); assert_eq!(payload["result"]["title"], "服务端页面");
assert!(payload["result"]["bodySummary"] assert!(
payload["result"]["bodySummary"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("章节一")); .contains("章节一")
);
assert_eq!(payload["audit"]["effect"], "read"); assert_eq!(payload["audit"]["effect"], "read");
} }
@@ -1814,10 +1846,12 @@ mod tests {
json!("heading_1") json!("heading_1")
); );
assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一")); assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一"));
assert!(payload["result"]["blocks"][0]["revisionRef"] assert!(
payload["result"]["blocks"][0]["revisionRef"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.starts_with("pageRev:7:block:heading_1:hash:")); .starts_with("pageRev:7:block:heading_1:hash:")
);
} }
#[tokio::test] #[tokio::test]
@@ -1944,10 +1978,12 @@ mod tests {
assert_eq!(payload["result"]["scope"], "selection"); assert_eq!(payload["result"]["scope"], "selection");
assert_eq!(payload["result"]["format"], "page_xml"); assert_eq!(payload["result"]["format"], "page_xml");
assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1); assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1);
assert!(payload["result"]["content"] assert!(
payload["result"]["content"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("<block id=\"heading_1\"")); .contains("<block id=\"heading_1\"")
);
assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1"); assert_eq!(payload["result"]["allowedTargetBlockIds"][0], "heading_1");
} }
@@ -2210,10 +2246,12 @@ mod tests {
insert_payload["result"]["changedBlocks"][0]["op"], insert_payload["result"]["changedBlocks"][0]["op"],
json!("insert_after") json!("insert_after")
); );
assert!(insert_payload["result"]["changedBlocks"][0]["blockId"] assert!(
insert_payload["result"]["changedBlocks"][0]["blockId"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.starts_with("ai_block_")); .starts_with("ai_block_")
);
let delete_response = app() let delete_response = app()
.oneshot( .oneshot(
@@ -2565,9 +2603,11 @@ mod tests {
.as_array() .as_array()
.expect("insertedBlockIds"); .expect("insertedBlockIds");
assert_eq!(inserted_ids.len(), 2); assert_eq!(inserted_ids.len(), 2);
assert!(inserted_ids assert!(
inserted_ids
.iter() .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!( assert_eq!(
payload["result"]["changedBlocks"] payload["result"]["changedBlocks"]
.as_array() .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::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
ProjectionSnapshotSpec, subtree_query,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[cfg(test)] #[cfg(test)]
static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 = static LOCAL_FOLDER_PROJECTION_TEST_BLOCK_MS: std::sync::atomic::AtomicU64 =
@@ -264,9 +264,9 @@ pub async fn graph(
#[cfg(test)] #[cfg(test)]
mod tests { 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 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 axum::http::{Request, StatusCode};
use serde_json::Value; use serde_json::Value;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -287,7 +287,7 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, 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, mutation_fixtures_json: None,
dev_user_id: "dev-user".into(), dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
@@ -409,7 +409,8 @@ mod tests {
assert_eq!(payload["ok"], true); assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["projection"], "file_tree"); assert_eq!(payload["result"]["projection"], "file_tree");
assert_eq!(payload["result"]["sourceKind"], "local_folder"); assert_eq!(payload["result"]["sourceKind"], "local_folder");
assert!(payload["result"]["items"] assert!(
payload["result"]["items"]
.as_array() .as_array()
.expect("items") .expect("items")
.iter() .iter()
@@ -419,7 +420,8 @@ mod tests {
.as_str() .as_str()
.unwrap_or("") .unwrap_or("")
.starts_with("local-md:") .starts_with("local-md:")
&& item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")); && item["resourceMeta"]["extra"]["source"]["relativePath"] == "本地页面")
);
let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&root);
} }
@@ -513,9 +515,11 @@ mod tests {
assert!(titles.contains(&"README.md")); assert!(titles.contains(&"README.md"));
assert!(titles.contains(&"nested")); assert!(titles.contains(&"nested"));
assert!(!titles.contains(&"deep.md")); assert!(!titles.contains(&"deep.md"));
assert!(items assert!(
items
.iter() .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); let _ = std::fs::remove_dir_all(&root);
} }
@@ -676,11 +680,13 @@ mod tests {
item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap" "mindmap"
); );
assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"] assert!(
item_by_row_id["asset-folder:mind_1"]["capabilities"]
.as_array() .as_array()
.expect("capabilities") .expect("capabilities")
.iter() .iter()
.any(|value| value == "expand")); .any(|value| value == "expand")
);
} }
#[tokio::test] #[tokio::test]
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, Sse}; use axum::response::sse::{Event as SseEvent, Sse};
use futures_util::stream;
use futures_util::StreamExt; use futures_util::StreamExt;
use futures_util::stream;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::convert::Infallible; use std::convert::Infallible;
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
use tokio::time::{interval, timeout, MissedTickBehavior}; use tokio::time::{MissedTickBehavior, interval, timeout};
type BoxedEventStream = type BoxedEventStream =
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>; Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
@@ -29,6 +29,7 @@ type BoxedEventStream =
pub struct LocalFolderEventsQuery { pub struct LocalFolderEventsQuery {
pub root_uri: String, pub root_uri: String,
pub document_id: Option<String>, pub document_id: Option<String>,
pub resource_path: Option<String>,
pub tree_live: Option<bool>, pub tree_live: Option<bool>,
} }
@@ -68,7 +69,8 @@ async fn build_document_events_stream(
let document_relative_path = query let document_relative_path = query
.document_id .document_id
.as_deref() .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 let subscription = state
.local_folder_watcher_registry() .local_folder_watcher_registry()
.subscribe(&canonical_root) .subscribe(&canonical_root)
@@ -78,6 +80,7 @@ async fn build_document_events_stream(
"sourceKind": "local_folder", "sourceKind": "local_folder",
"rootUri": subscription.root_uri(), "rootUri": subscription.root_uri(),
"documentId": query.document_id, "documentId": query.document_id,
"resourcePath": query.resource_path,
"revision": system_time_ms(SystemTime::now()), "revision": system_time_ms(SystemTime::now()),
}); });
let stream = stream::unfold( let stream = stream::unfold(
@@ -117,7 +120,7 @@ async fn build_document_events_stream(
/// with full sidebar + file tree projections. /// with full sidebar + file tree projections.
/// ///
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created. /// 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( async fn build_tree_live_stream(
state: AppState, state: AppState,
context: RequestContext, 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() 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 { fn document_event_targets_relative_path(payload: &Value, expected: &str) -> bool {
let relative_path = payload let relative_path = payload
.get("relativePath") .get("relativePath")
@@ -457,7 +472,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 crate::routes::local_folder_source::initialize_local_workspace_for_actor;
use axum::body::Body; use axum::body::Body;
use axum::http::Request; 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] #[test]
fn build_tree_snapshot_payload_has_required_fields() { fn build_tree_snapshot_payload_has_required_fields() {
let sidebar_projection = json!({ let sidebar_projection = json!({
@@ -697,11 +741,13 @@ mod tests {
&& parent["reason"].as_str() == Some("child-watch")), && parent["reason"].as_str() == Some("child-watch")),
"watch batch 应声明 docs affected parent: {payload}" "watch batch 应声明 docs affected parent: {payload}"
); );
assert!(payload["eventKinds"] assert!(
payload["eventKinds"]
.as_array() .as_array()
.expect("event kinds") .expect("event kinds")
.iter() .iter()
.any(|kind| kind.as_str() == Some("Modify(Data)"))); .any(|kind| kind.as_str() == Some("Modify(Data)"))
);
let _ = std::fs::remove_dir_all(root); 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::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot; 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::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 bridge_runtime::project_legacy_content_to_block_document;
use control_plane::AppendAuditInput; use control_plane::AppendAuditInput;
use control_plane::{ use control_plane::{
@@ -24,7 +24,7 @@ use core_protocol::{
}; };
use reqwest::Url; use reqwest::Url;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{Map, Value, json};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
@@ -41,6 +41,9 @@ static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS: std::sync::atomic::AtomicU64 =
#[cfg(test)] #[cfg(test)]
static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 = static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0); 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)] #[derive(Debug, Clone)]
struct LocalPageTreeSnapshotCacheEntry { struct LocalPageTreeSnapshotCacheEntry {
@@ -52,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>, Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
> = OnceLock::new(); > = OnceLock::new();
fn local_page_tree_snapshot_cache( fn local_page_tree_snapshot_cache()
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> { -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) 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() { 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_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst);
LOCAL_PAGE_TREE_SNAPSHOT_SCAN_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 Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() {
if let Ok(mut cache) = cache.lock() { if let Ok(mut cache) = cache.lock() {
cache.clear(); cache.clear();
@@ -69,8 +77,13 @@ pub(crate) fn reset_local_page_tree_snapshot_test_loads() {
} }
#[cfg(test)] #[cfg(test)]
pub(crate) fn local_page_tree_snapshot_test_loads() -> u64 { pub(crate) fn local_page_tree_snapshot_test_loads_for_root(root_uri: &str) -> u64 {
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) 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)] #[cfg(test)]
@@ -2784,6 +2797,15 @@ fn load_local_folder_page_tree_snapshot_for_scope(
) -> Result<ProjectionSnapshot, WebError> { ) -> Result<ProjectionSnapshot, WebError> {
#[cfg(test)] #[cfg(test)]
LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); 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 root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| { let canonical_root = root_path.canonicalize().map_err(|error| {
@@ -3255,10 +3277,12 @@ pub fn write_local_markdown_page_body(
relative_path, relative_path,
&request.document_id, &request.document_id,
); );
store.mark_saved( store.mark_saved_with_operation(
&ws_path, &ws_path,
file_version.to_string(), file_version.to_string(),
format!("sha256:{file_version}"), 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) .map(Value::from)
.unwrap_or(Value::Null), .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) Ok(result)
} }
@@ -3818,7 +3858,7 @@ pub(crate) fn write_local_folder_file_upload(
"上传目标必须是本地 root 内的目录", "上传目标必须是本地 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); let target = next_available_raw_path(&target_dir, &sanitized_name);
fs::write(&target, &file.bytes).map_err(|error| { fs::write(&target, &file.bytes).map_err(|error| {
WebError::bad_request_code( 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); let target = next_available_asset_path(&asset_dir, &sanitized_name);
if !target.starts_with(&canonical_root) { if !target.starts_with(&canonical_root) {
return Err(WebError::bad_request_code( 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()) .map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/"); .join("/");
if value.is_empty() { if value.is_empty() { None } else { Some(value) }
None
} else {
Some(value)
}
}) })
.map(|value| normalize_file_order_parent_key(&value)) .map(|value| normalize_file_order_parent_key(&value))
.unwrap_or_else(|| ".".to_string()) .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() { "asset" => match row.icon_hint.as_str() {
"mindmap" => KernelObjectKind::Mindmap, "mindmap" => KernelObjectKind::Mindmap,
"onlyoffice" | "office" => KernelObjectKind::OnlyOffice, "onlyoffice" | "office" | "word" | "ppt" | "sheet" => KernelObjectKind::OnlyOffice,
_ => KernelObjectKind::Attachment, _ => KernelObjectKind::Attachment,
}, },
_ => 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 { let workspace_path = ObjectWorkspacePath {
workspace_id: row.workspace_id.clone(), workspace_id: row.workspace_id.clone(),
source_kind: WorkspaceSourceKind::LocalFolder, source_kind: WorkspaceSourceKind::LocalFolder,
@@ -7531,7 +7572,7 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value {
object_kind, object_kind,
document_id: row.document_id.clone(), document_id: row.document_id.clone(),
block_id: None, block_id: None,
asset_id: None, asset_id: object_asset_id,
}, },
resource_kind: Some(row.row_kind.clone()), resource_kind: Some(row.row_kind.clone()),
}; };
@@ -7733,21 +7774,22 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
"pdf" "pdf"
} else if matches!( } else if matches!(
extension(&lower).as_deref(), extension(&lower).as_deref(),
Some( Some("doc" | "docx" | "odt" | "rtf")
"doc"
| "docx"
| "odt"
| "rtf"
| "ppt"
| "pptx"
| "odp"
| "xls"
| "xlsx"
| "ods"
| "csv"
)
) { ) {
"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")) { } else if matches!(extension(&lower).as_deref(), Some("epub" | "mobi")) {
"book" "book"
} else { } else {
@@ -7756,6 +7798,135 @@ fn icon_hint_for_entry(entry: &LocalFolderEntry) -> String {
.to_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> { fn extension(file_name: &str) -> Option<String> {
Path::new(file_name) Path::new(file_name)
.extension() .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 { fn next_available_path(directory: &Path, stem: &str, extension: &str) -> PathBuf {
let first = directory.join(format!("{stem}.{extension}")); let first = directory.join(format!("{stem}.{extension}"));
if !first.exists() { if !first.exists() {
@@ -8919,7 +9099,7 @@ fn system_time_ms(time: SystemTime) -> u128 {
.unwrap_or(0) .unwrap_or(0)
} }
fn local_markdown_conflict_detection_key( pub(crate) fn local_markdown_conflict_detection_key(
document_id: &str, document_id: &str,
markdown_path: &Path, markdown_path: &Path,
) -> Result<String, WebError> { ) -> Result<String, WebError> {
@@ -9044,6 +9224,10 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest,
LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
add_sqlite_local_access_grant_for_context, add_sqlite_local_access_grant_for_context,
create_default_local_workspace_for_actor_at_base, create_local_access_grant, 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, 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, resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, 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_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, write_sync_conflict_report,
LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest,
LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
}; };
use crate::app::{AppConfig, AppState}; use crate::app::{AppConfig, AppState};
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, Path as AxumPath, Query, State}; use axum::extract::{Extension, Path as AxumPath, Query, State};
use axum::http::{HeaderMap, Method, StatusCode}; use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use control_plane::{DirectoryGrantInput, UpsertUserInput}; use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::sync::Mutex; use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> { fn env_lock() -> &'static Mutex<()> {
@@ -9345,28 +9526,25 @@ mod tests {
"tableCell" "tableCell"
); );
assert_eq!( assert_eq!(
table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0] table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"],
["text"],
"" ""
); );
assert_eq!( assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"],
["text"],
"A" "A"
); );
assert_eq!( assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"]
["marks"][0]["type"], [0]["type"],
"code" "code"
); );
assert_eq!( assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"],
["text"],
"B" "B"
); );
assert_eq!( assert_eq!(
table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"]
["marks"][0]["type"], [0]["type"],
"bold" "bold"
); );
} }
@@ -9469,10 +9647,12 @@ mod tests {
let body = serde_json::to_value(&aggregate.body).expect("body json"); let body = serde_json::to_value(&aggregate.body).expect("body json");
assert_eq!(body["fileVersion"], body["conflictDetectionKey"]); assert_eq!(body["fileVersion"], body["conflictDetectionKey"]);
assert!(body["fileVersion"] assert!(
body["fileVersion"]
.as_str() .as_str()
.expect("file version") .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); let _ = std::fs::remove_dir_all(&root);
} }
@@ -9494,10 +9674,12 @@ mod tests {
.expect("save"); .expect("save");
assert_eq!(result["fileVersion"], result["conflict_detection_key"]); assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
assert!(result["fileVersion"] assert!(
result["fileVersion"]
.as_str() .as_str()
.expect("file version") .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); let _ = std::fs::remove_dir_all(&root);
} }
@@ -9516,6 +9698,8 @@ mod tests {
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.clone(), root_uri: root_uri.clone(),
expected_file_version: aggregate.body.file_version.as_str().map(ToOwned::to_owned), 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()), base_content_hash: Some("sha256:test-base".into()),
content_format: "editorBlocks".into(), content_format: "editorBlocks".into(),
content: serde_json::json!([ content: serde_json::json!([
@@ -9530,6 +9714,8 @@ mod tests {
assert_eq!(result["compatCommand"], "page.body.save"); assert_eq!(result["compatCommand"], "page.body.save");
assert_eq!(result["contentFormat"], "editorBlocks"); assert_eq!(result["contentFormat"], "editorBlocks");
assert_eq!(result["editorSource"], "unit-test"); 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"]); assert_eq!(result["fileVersion"], result["conflict_detection_key"]);
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("# Written")); assert!(saved.contains("# Written"));
@@ -10614,10 +10800,12 @@ fn main() {}
assert_eq!(created["grant"]["ownerUserId"], "user_owner"); assert_eq!(created["grant"]["ownerUserId"], "user_owner");
assert_eq!(created["grant"]["targetUserId"], "user_target"); assert_eq!(created["grant"]["targetUserId"], "user_target");
assert_eq!(created["grant"]["permission"], "write"); assert_eq!(created["grant"]["permission"], "write");
assert!(created["grant"]["shareId"] assert!(
created["grant"]["shareId"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.starts_with("folder_")); .starts_with("folder_")
);
let outside_error = create_user_share_grant( let outside_error = create_user_share_grant(
Extension(request_context("user_owner", "user")), Extension(request_context("user_owner", "user")),
@@ -10887,11 +11075,13 @@ fn main() {}
assert_eq!(stored.len(), 1); assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, link_id); assert_eq!(stored[0].id, link_id);
assert_ne!(stored[0].token_hash, "visible-token"); assert_ne!(stored[0].token_hash, "visible-token");
assert!(state assert!(
state
.control_plane() .control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) .resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve share link") .expect("resolve share link")
.is_some()); .is_some()
);
let (_, Json(listed)) = get_share_links( let (_, Json(listed)) = get_share_links(
State(state.clone()), State(state.clone()),
@@ -10924,11 +11114,13 @@ fn main() {}
.expect("share revoked broadcast delta"); .expect("share revoked broadcast delta");
assert_eq!(revoked_delta["kind"], "control_plane_event"); assert_eq!(revoked_delta["kind"], "control_plane_event");
assert_eq!(revoked_delta["eventType"], "control.share.revoked"); assert_eq!(revoked_delta["eventType"], "control.share.revoked");
assert!(state assert!(
state
.control_plane() .control_plane()
.resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) .resolve_share_link(&control_plane::share_token_hash_v1("visible-token"))
.expect("resolve revoked share link") .expect("resolve revoked share link")
.is_none()); .is_none()
);
assert_eq!( assert_eq!(
state state
.control_plane() .control_plane()
@@ -11132,11 +11324,13 @@ fn main() {}
payload["workspace"]["manifest"]["ownerId"], payload["workspace"]["manifest"]["ownerId"],
"user@example.com" "user@example.com"
); );
assert!(payload["workspace"]["manifest"]["capabilities"] assert!(
payload["workspace"]["manifest"]["capabilities"]
.as_array() .as_array()
.expect("capabilities") .expect("capabilities")
.iter() .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) ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri)
.expect("owner can access managed workspace"); .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) execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None)
.expect("delete loose markdown"); .expect("delete loose markdown");
assert!(!root.join("Renamed.md").exists()); assert!(!root.join("Renamed.md").exists());
assert!(root assert!(
.join(".mnote") root.join(".mnote")
.join("trash") .join("trash")
.join("Renamed.md") .join("Renamed.md")
.is_file()); .is_file()
);
assert_eq!(deleted["resourceKind"].as_str(), Some("markdown")); assert_eq!(deleted["resourceKind"].as_str(), Some("markdown"));
let restored = 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("Page.md"), "").expect("write md");
std::fs::write(root.join("Page").join("思维导图123456.json"), "{}").expect("write mindmap"); 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("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 root_uri = format!("file://{}", root.display());
let snapshot = let snapshot =
@@ -12456,17 +12662,60 @@ fn main() {}
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(), mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
Some("mindmap") Some("mindmap")
); );
assert_eq!(
mindmap["resourceMeta"]["workspacePath"]["objectIdentity"]["assetId"].as_str(),
Some("local-file:Page/思维导图123456.json")
);
let office = items let office = items
.iter() .iter()
.find(|item| item["title"].as_str() == Some("report.docx")) .find(|item| item["title"].as_str() == Some("report.docx"))
.expect("office row"); .expect("office row");
assert_eq!(office["rowKind"].as_str(), Some("asset")); 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!( assert_eq!(
office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(), office["resourceMeta"]["workspacePath"]["objectIdentity"]["objectKind"].as_str(),
Some("only_office") 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); let _ = std::fs::remove_dir_all(&root);
} }
@@ -12497,6 +12746,8 @@ fn main() {}
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
root_uri: root_uri.clone(), root_uri: root_uri.clone(),
expected_file_version: None, 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, base_content_hash: None,
content_format: "editorBlocks".into(), content_format: "editorBlocks".into(),
content: serde_json::json!([ content: serde_json::json!([
@@ -12875,6 +13126,30 @@ fn main() {}
markdown_asset["ownerDocumentId"], markdown_asset["ownerDocumentId"],
"local-md:docs~2FREADME~2FREADME.md" "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 = let uploaded_asset_index =
std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json")) std::fs::read_to_string(root.join(".mnote").join("uploaded-assets.json"))
.expect("uploaded asset index"); .expect("uploaded asset index");
@@ -1,6 +1,6 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options}; use comrak::{Arena, Options, parse_document};
use serde_json::{json, Map, Value}; use serde_json::{Map, Value, json};
use std::collections::BTreeSet; use std::collections::BTreeSet;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter, parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -852,41 +852,55 @@ mod tests {
.iter() .iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result"); .expect("home result");
assert!(home["tags"] assert!(
home["tags"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|tag| tag.as_str() == Some("alpha"))); .any(|tag| tag.as_str() == Some("alpha"))
assert!(home["backlinks"] );
assert!(
home["backlinks"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|link| link.as_str() == Some("Daily"))); .any(|link| link.as_str() == Some("Daily"))
assert!(home["backlinks"] );
assert!(
home["backlinks"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|link| link.as_str() == Some("docs/child.md"))); .any(|link| link.as_str() == Some("docs/child.md"))
assert!(home["resourceRefs"] );
assert!(
home["resourceRefs"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))); .any(|reference| reference.as_str() == Some("assets/spec.pdf"))
assert!(home["resourceRefs"] );
assert!(
home["resourceRefs"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))); .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
assert!(home["resourceRefs"] );
assert!(
home["resourceRefs"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx"))); .any(|reference| reference.as_str() == Some("office/report.xlsx"))
assert!(home["publicPath"] );
assert!(
home["publicPath"]
.as_str() .as_str()
.is_some_and(|path| path.starts_with( .is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
))); ))
);
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid: // 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
let child_search = query_local_search_index( let child_search = query_local_search_index(
@@ -915,11 +929,12 @@ mod tests {
Some("local-mdid:child-page") Some("local-mdid:child-page")
); );
assert!(root assert!(
.join(".mnote") root.join(".mnote")
.join("index") .join("index")
.join("search-index.json") .join("search-index.json")
.exists()); .exists()
);
let mindmap_projection = query_local_search_index( let mindmap_projection = query_local_search_index(
&root, &root,
&format!("file://{}", root.display()), &format!("file://{}", root.display()),
@@ -931,19 +946,19 @@ mod tests {
false, false,
) )
.expect("mindmap projection"); .expect("mindmap projection");
assert!(mindmap_projection["results"] assert!(
mindmap_projection["results"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap") .any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json") && item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"] && item["publicPath"].as_str().is_some_and(|path| path
.as_str()
.is_some_and(|path| path
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri=")) .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
&& item["publicPath"] && item["publicPath"]
.as_str() .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( let office_projection = query_local_search_index(
&root, &root,
&format!("file://{}", root.display()), &format!("file://{}", root.display()),
@@ -955,12 +970,14 @@ mod tests {
false, false,
) )
.expect("office projection"); .expect("office projection");
assert!(office_projection["results"] assert!(
office_projection["results"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|item| item["resourceType"].as_str() == Some("office") .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); let _ = fs::remove_dir_all(&root);
} }
@@ -987,10 +1004,12 @@ mod tests {
let index = read_local_search_index(&root) let index = read_local_search_index(&root)
.expect("read index") .expect("read index")
.expect("index exists"); .expect("index exists");
assert!(index assert!(
index
.documents .documents
.iter() .iter()
.any(|document| document.path == "README.md")); .any(|document| document.path == "README.md")
);
let child = index let child = index
.documents .documents
.iter() .iter()
@@ -1006,14 +1025,18 @@ mod tests {
let index = read_local_search_index(&root) let index = read_local_search_index(&root)
.expect("read index") .expect("read index")
.expect("index exists"); .expect("index exists");
assert!(!index assert!(
!index
.documents .documents
.iter() .iter()
.any(|document| document.path == "docs/child.md")); .any(|document| document.path == "docs/child.md")
assert!(index );
assert!(
index
.documents .documents
.iter() .iter()
.any(|document| document.path == "README.md")); .any(|document| document.path == "README.md")
);
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
+12 -12
View File
@@ -3,24 +3,24 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::command_support::{build_runtime_command_plan, runtime_context}; use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
use crate::transport::convex::{ 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, persist_runtime_command_artifacts,
}; };
use axum::extract::{Multipart, Query, State}; use axum::extract::{Multipart, Query, State};
use axum::http::{header, HeaderMap}; use axum::http::{HeaderMap, header};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{Extension, Json}; 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::{ use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
RuntimeSourceWire, RuntimeTargetWire, build_runtime_command_artifact_plan,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime; use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1); 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" { if !actor_id.is_empty() && actor_id != "anonymous" {
return actor_id.to_string(); 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(), state.config(),
context, context,
"users:currentUser", "users:currentUser",
@@ -360,7 +360,7 @@ pub async fn upload(
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?; let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
let user_id = current_user_id(&state, &context).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(), state.config(),
&context, &context,
"mediaAssets:generateUploadUrl", "mediaAssets:generateUploadUrl",
@@ -421,7 +421,7 @@ pub async fn upload(
.map(|value| format!("mindmaps/{value}")); .map(|value| format!("mindmaps/{value}"));
let id = new_asset_id(); let id = new_asset_id();
let kind = asset_type(&file.content_type); let kind = asset_type(&file.content_type);
let created = execute_convex_mutation_by_name( let created = execute_retired_mutation_by_name(
state.config(), state.config(),
&context, &context,
"mediaAssets:createWithStorage", "mediaAssets:createWithStorage",
@@ -541,7 +541,7 @@ pub async fn sign(
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?; .ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
let user_id = current_user_id(&state, &context).await; 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(), state.config(),
&context, &context,
"mediaAssets:getById", "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(), state.config(),
&context, &context,
"mediaAssets:refreshUrl", "mediaAssets:refreshUrl",
+20 -16
View File
@@ -1,24 +1,25 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; 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::{ use crate::routes::local_folder_source::{
ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state, ensure_local_workspace_read_access_with_state, ensure_local_workspace_write_access_with_state,
read_local_mindmap_data, write_local_mindmap_data, read_local_mindmap_data, write_local_mindmap_data,
}; };
use crate::routes::query_support::{ 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,
fetch_documents_meta_via_convex, fetch_query_data_via_convex, resolve_effective_workspace_id, 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::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{ use bridge_runtime::{
apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeTargetWire, apply_mindmap_kernel_commands_to_value,
}; };
use serde::Deserialize; 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_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport"; const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport";
@@ -122,11 +123,14 @@ async fn resolve_mindmap_workspace_id(
// 思维导图 runtime 的历史请求体不一定带 workspaceId // 思维导图 runtime 的历史请求体不一定带 workspaceId
// 这里从页面 meta 反查,确保后续 command artifacts 能进入正确 workspace 的实时流。 // 这里从页面 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)) 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 execution
.artifacts .artifacts
.as_ref() .as_ref()
@@ -175,7 +179,7 @@ pub async fn get_mindmap(
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?; resolve_effective_workspace_id(&context, params.workspace_id.as_deref(), false)?;
let query_name = resolve_query_name(&params); let query_name = resolve_query_name(&params);
let result = execute_runtime_query_via_convex( let result = execute_runtime_query_via_legacy_cloud(
state.config(), state.config(),
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -357,7 +361,7 @@ pub async fn apply_mindmap_command(
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
effective_workspace_id.as_deref(), 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(), state.config(),
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -439,7 +443,7 @@ pub async fn apply_mindmap_command(
validate_only: false, validate_only: false,
}; };
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
effective_workspace_id.as_deref(), effective_workspace_id.as_deref(),
@@ -465,10 +469,10 @@ pub async fn apply_mindmap_command(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::path::PathBuf; use std::path::PathBuf;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -150,7 +150,7 @@ pub async fn mindmap_object_shell(
"documentId": doc_id, "documentId": doc_id,
"mindmapId": mindmap_id "mindmapId": mindmap_id
}, },
"sourceKind": source_kind.unwrap_or("convex_workspace"), "sourceKind": source_kind.unwrap_or("local_folder"),
"rootUri": root_uri.unwrap_or(""), "rootUri": root_uri.unwrap_or(""),
"revision": serde_json::Value::Null, "revision": serde_json::Value::Null,
"conflictDetectionKey": serde_json::Value::Null, "conflictDetectionKey": serde_json::Value::Null,
@@ -167,7 +167,7 @@ pub async fn mindmap_object_shell(
"shell": "mindmap", "shell": "mindmap",
"documentId": doc_id, "documentId": doc_id,
"mindmapId": mindmap_id, "mindmapId": mindmap_id,
"sourceKind": source_kind.unwrap_or("convex_workspace"), "sourceKind": source_kind.unwrap_or("local_folder"),
"rootUri": root_uri.unwrap_or(""), "rootUri": root_uri.unwrap_or(""),
"projection": { "projection": {
"schema": "mnote.mindmap.simple_mind_map_scene.v1", "schema": "mnote.mindmap.simple_mind_map_scene.v1",
@@ -217,7 +217,7 @@ pub async fn mindmap_object_shell(
crate::ssr::MNOTE_CSS, crate::ssr::MNOTE_CSS,
escape_html(&doc_id), escape_html(&doc_id),
escape_html(&mindmap_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("")), escape_html(root_uri.unwrap_or("")),
body_content, body_content,
escape_script_json(&editor_bootstrap_json), escape_script_json(&editor_bootstrap_json),
@@ -326,8 +326,8 @@ fn render_mindmap_standalone_bootstrap_script() -> &'static str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
+347 -5
View File
@@ -17,6 +17,7 @@ mod local_search_index;
mod media; mod media;
mod mindmap_api; mod mindmap_api;
mod mindmap_shell; mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice; mod onlyoffice;
mod page_ai_workflow; mod page_ai_workflow;
mod query_support; mod query_support;
@@ -34,14 +35,16 @@ pub(crate) mod web_shell;
mod ws; mod ws;
pub(crate) use local_folder_source::{ 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, update_local_markdown_title, write_local_markdown_page_body,
}; };
pub(crate) use local_search_index::refresh_local_search_index_for_path; pub(crate) use local_search_index::refresh_local_search_index_for_path;
use crate::app::AppState; use crate::app::AppState;
use axum::routing::{any, delete, get, post, put};
use axum::Router; use axum::Router;
use axum::extract::DefaultBodyLimit;
use axum::routing::{any, delete, get, post, put};
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone(); 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", "/api/editor/image-placeholder.svg",
get(web_shell::editor_image_placeholder_asset), 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( .route(
"/api/mnote-browser-runtime/resource-open-runtime.js", "/api/mnote-browser-runtime/resource-open-runtime.js",
get(web_shell::resource_open_runtime_asset), get(web_shell::resource_open_runtime_asset),
@@ -286,6 +296,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/sidebar/shortcuts/{shortcut_id}", "/api/sidebar/shortcuts/{shortcut_id}",
delete(sidebar_shortcuts::delete_shortcut), delete(sidebar_shortcuts::delete_shortcut),
) )
.route(
"/api/navigation/recent",
get(navigation_recent::list_recent).post(navigation_recent::upsert_recent),
)
.route( .route(
"/api/admin/access-policy", "/api/admin/access-policy",
get(local_folder_source::get_local_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/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content)) .route("/api/documents/content", get(documents::content))
.route("/api/documents/buffer-state", get(documents::buffer_state)) .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/purge", post(documents::purge))
.route("/api/documents/empty-trash", post(documents::empty_trash)) .route("/api/documents/empty-trash", post(documents::empty_trash))
.route("/api/documents/title", post(documents::title)) .route("/api/documents/title", post(documents::title))
@@ -440,7 +458,8 @@ pub fn build_router(state: AppState) -> Router {
) )
.route( .route(
"/api/local-folder/assets/upload", "/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( .route(
"/api/local-folder/files/open", "/api/local-folder/files/open",
@@ -552,9 +571,12 @@ pub fn build_router(state: AppState) -> Router {
mod tests { mod tests {
use super::build_router; use super::build_router;
use crate::app::{AppConfig, AppState}; use crate::app::{AppConfig, AppState};
use axum::body::Body; use crate::context::RequestContext;
use axum::http::{Request, StatusCode};
use axum::Router; 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; use tower::ServiceExt;
fn app(enable_debug_shell_routes: bool) -> Router { fn app(enable_debug_shell_routes: bool) -> Router {
@@ -641,6 +663,16 @@ mod tests {
("GET", "/api/onlyoffice/proxy?u=x"), ("GET", "/api/onlyoffice/proxy?u=x"),
("POST", "/api/onlyoffice/callback"), ("POST", "/api/onlyoffice/callback"),
("POST", "/api/onlyoffice/forcesave"), ("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"), ("POST", "/api/media/upload"),
("GET", "/api/media/sign?assetId=x"), ("GET", "/api/media/sign?assetId=x"),
("POST", "/api/tree/filetree/upload-target-preflight"), ("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] #[tokio::test]
async fn mnote_browser_runtime_assets_are_explicitly_mounted() { async fn mnote_browser_runtime_assets_are_explicitly_mounted() {
for path in [ 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::app::AppState;
use crate::error::WebError; use crate::error::WebError;
use adapter_onlyoffice::{ use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput, OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback,
OnlyOfficeProxyPreparationInput, prepare_proxy_request, sign_config,
}; };
use axum::Json;
use axum::body::{Body, Bytes}; use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State}; 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::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine; use base64::Engine;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use hyper::upgrade::Upgraded; use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::{Path as FsPath, PathBuf}; use std::path::{Path as FsPath, PathBuf};
use std::time::Duration; use std::time::Duration;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::handshake::derive_accept_key; use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use tokio_tungstenite::tungstenite::protocol::Role; 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 ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082"; 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( onlyoffice_storage_host_override: env_or_dotenv(
"NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE", "NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE",
), ),
convex_origin: proxy_origin_env("CONVEX_SELF_HOSTED_URL") convex_origin: None,
.or_else(|| proxy_origin_env("NEXT_PUBLIC_CONVEX_URL")),
supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY") supabase_anon_key: env_or_dotenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
.or_else(|| env_or_dotenv("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() { fn stable_doc_key_uses_onlyoffice_safe_characters() {
let key = stable_doc_key("asset_1", "kg2abc:def", "", ""); let key = stable_doc_key("asset_1", "kg2abc:def", "", "");
assert!(key.len() <= 128); assert!(key.len() <= 128);
assert!(key assert!(
.chars() key.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
);
assert!(key.starts_with("asset_1_")); assert!(key.starts_with("asset_1_"));
} }
@@ -1722,9 +1722,10 @@ mod tests {
); );
assert!(key.len() <= 128); assert!(key.len() <= 128);
assert!(key.starts_with("mnote_")); assert!(key.starts_with("mnote_"));
assert!(key assert!(
.chars() key.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))
);
assert!(!key.contains('/')); assert!(!key.contains('/'));
assert!(!key.contains(':')); assert!(!key.contains(':'));
assert!(!key.contains('重')); assert!(!key.contains('重'));
@@ -2024,8 +2025,11 @@ mod tests {
let request = captured.await.expect("captured"); let request = captured.await.expect("captured");
assert_eq!(payload["error"], 0); assert_eq!(payload["error"], 0);
assert!(request assert!(
.starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1")); 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#""status":2"#));
assert!(request.contains(r#""key":"doc_key""#)); assert!(request.contains(r#""key":"doc_key""#));
} }
@@ -2080,8 +2084,10 @@ mod tests {
assert_eq!(payload["ok"], true); assert_eq!(payload["ok"], true);
assert_eq!(payload["via"], "forcesave"); assert_eq!(payload["via"], "forcesave");
assert!(request assert!(
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")
);
} }
#[tokio::test] #[tokio::test]
@@ -2,10 +2,10 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::hermes_tools::ToolCallInput; use crate::hermes_tools::ToolCallInput;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::Json; use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
@@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{direct_block_edit_operations, extract_operations_from_model_text}; use super::{direct_block_edit_operations, extract_operations_from_model_text};
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use axum::routing::post; use axum::routing::post;
use axum::{Json, Router}; use axum::{Json, Router};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::fs; use std::fs;
use std::sync::Mutex; use std::sync::Mutex;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -952,14 +952,18 @@ mod tests {
.expect("body"); .expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json"); let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true); assert_eq!(payload["ok"], true);
assert!(payload["message"] assert!(
payload["message"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("已读取第一段:第一段")); .contains("已读取第一段:第一段")
assert!(payload["message"] );
assert!(
payload["message"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("测试123")); .contains("测试123")
);
std::env::remove_var("HERMES_HOME"); std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home); let _ = fs::remove_dir_all(&hermes_home);
@@ -1,15 +1,14 @@
use crate::app::AppConfig; use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::transport::convex::execute_convex_query_plan; use crate::transport::convex::execute_retired_query_plan;
use bridge_runtime::{ use bridge_runtime::{
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request,
RuntimeSourceWire, execute_runtime_input, execute_runtime_query,
}; };
use core_protocol::{GetPageMeta, QueryEnvelope}; use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value; use serde_json::Value;
use storage_convex_bridge::{build_query_request, BridgeContext};
pub fn resolve_effective_workspace_id( pub fn resolve_effective_workspace_id(
context: &RequestContext, 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, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?; 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, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
document_id: &str, document_id: &str,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?; 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( 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)) .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, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
query: RuntimeQueryEnvelopeWire, query: RuntimeQueryEnvelopeWire,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let data = 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) 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::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::command_support::{ 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::{ use crate::routes::local_folder_source::{
ensure_local_workspace_access, execute_local_tree_command, ensure_local_workspace_access, execute_local_tree_command,
}; };
use crate::transport::convex::{ 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, persist_runtime_command_artifacts,
}; };
use axum::Json;
use axum::extract::{Extension, Path, Query, State}; use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{ use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire,
RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire, RuntimeTargetWire, build_runtime_command_artifact_plan,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
@@ -118,7 +118,7 @@ async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
}) })
.unwrap_or(false); .unwrap_or(false);
if has_auth_cookie { if has_auth_cookie {
if let Ok(user) = execute_convex_query_by_name( if let Ok(user) = execute_retired_query_by_name(
state.config(), state.config(),
context, context,
"users:currentUser", "users:currentUser",
@@ -372,7 +372,7 @@ async fn fetch_document_workspace_id(
context: &RequestContext, context: &RequestContext,
document_id: &str, document_id: &str,
) -> Option<String> { ) -> Option<String> {
execute_convex_query_by_name( execute_retired_query_by_name(
state.config(), state.config(),
context, context,
"documents:getMeta", "documents:getMeta",
@@ -401,7 +401,7 @@ async fn fetch_table_meta(
user_id: &str, user_id: &str,
table_id: &str, table_id: &str,
) -> Option<Value> { ) -> Option<Value> {
execute_convex_query_by_name( execute_retired_query_by_name(
state.config(), state.config(),
context, context,
"tables:get", "tables:get",
@@ -479,7 +479,7 @@ async fn fetch_media_asset_meta(
user_id: &str, user_id: &str,
asset_id: &str, asset_id: &str,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let assets = execute_convex_query_by_name( let assets = execute_retired_query_by_name(
state.config(), state.config(),
context, context,
"mediaAssets:listByIds", "mediaAssets:listByIds",
@@ -606,7 +606,7 @@ pub async fn media_batch(
body.new_name.as_deref().unwrap_or_default(), body.new_name.as_deref().unwrap_or_default(),
"newName", "newName",
)?; )?;
execute_convex_mutation_by_name( execute_retired_mutation_by_name(
state.config(), state.config(),
&context, &context,
"mediaAssets:patchById", "mediaAssets:patchById",
@@ -629,7 +629,7 @@ pub async fn media_batch(
body.target_document_id.as_deref().unwrap_or_default(), body.target_document_id.as_deref().unwrap_or_default(),
"targetDocumentId", "targetDocumentId",
)?; )?;
execute_convex_mutation_by_name( execute_retired_mutation_by_name(
state.config(), state.config(),
&context, &context,
"mediaAssets:patchById", "mediaAssets:patchById",
@@ -646,7 +646,7 @@ pub async fn media_batch(
) )
.await?; .await?;
} }
let command_result = execute_runtime_command_via_convex_with_artifacts( let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -712,7 +712,7 @@ pub async fn media_purge(
"assetId": asset_id, "assetId": asset_id,
}), }),
); );
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -729,7 +729,7 @@ pub async fn media_empty_trash(
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; 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(), state.config(),
&context, &context,
"mediaAssets:emptyTrashByWorkspace", "mediaAssets:emptyTrashByWorkspace",
@@ -812,7 +812,7 @@ pub async fn mindmap_delete(
"mindmapId": mindmap_id, "mindmapId": mindmap_id,
}), }),
); );
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -897,7 +897,7 @@ pub async fn mindmap_trash_action(
"mindmapId": mindmap_id, "mindmapId": mindmap_id,
}), }),
); );
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -916,7 +916,7 @@ pub async fn mindmap_empty_trash(
Json(body): Json<WorkspaceTrashRequest>, Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; 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(), state.config(),
&context, &context,
"mindmaps:emptyTrashByWorkspace", "mindmaps:emptyTrashByWorkspace",
@@ -980,7 +980,7 @@ pub async fn table_create(
.with_context(&context) .with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
})?; })?;
let result = execute_convex_mutation_by_name( let result = execute_retired_mutation_by_name(
state.config(), state.config(),
&context, &context,
"tables:create", "tables:create",
@@ -1087,7 +1087,7 @@ async fn table_action(
"tableId": table_id, "tableId": table_id,
}), }),
); );
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_legacy_cloud_with_artifacts(
&state, &state,
&context, &context,
workspace_id.as_deref(), workspace_id.as_deref(),
@@ -1108,7 +1108,7 @@ pub async fn table_empty_trash(
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let user_id = current_user_id(&state, &context).await; let user_id = current_user_id(&state, &context).await;
let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; 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(), state.config(),
&context, &context,
"tables:emptyTrashByWorkspace", "tables:emptyTrashByWorkspace",
@@ -1148,8 +1148,8 @@ pub async fn table_empty_trash(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::Request; use axum::http::Request;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -1311,11 +1311,12 @@ mod tests {
); );
assert_eq!(payload["result"]["sourceKind"], "local_folder"); assert_eq!(payload["result"]["sourceKind"], "local_folder");
assert!(!root.join("Page").join("map.mindmap.json").exists()); assert!(!root.join("Page").join("map.mindmap.json").exists());
assert!(root assert!(
.join(".mnote") root.join(".mnote")
.join("trash") .join("trash")
.join("map.mindmap.json") .join("map.mindmap.json")
.exists()); .exists()
);
assert_eq!( assert_eq!(
std::fs::read_to_string(&markdown_path).expect("read markdown after trash"), std::fs::read_to_string(&markdown_path).expect("read markdown after trash"),
original_markdown, original_markdown,
@@ -1421,11 +1422,13 @@ mod tests {
purged_payload["result"]["canonicalCommand"], purged_payload["result"]["canonicalCommand"],
"tree.resource.purge" "tree.resource.purge"
); );
assert!(!root assert!(
!root
.join(".mnote") .join(".mnote")
.join("trash") .join("trash")
.join("map.mindmap.json") .join("map.mindmap.json")
.exists()); .exists()
);
let _ = std::fs::remove_dir_all(&root); 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::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ 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, resolve_effective_workspace_id,
}; };
use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{local_folder_source, local_search_index}; use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage; use crate::ssr::pages::search::SearchPage;
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize; 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_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name"; 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()), "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, config,
context, context,
Some(workspace_id), Some(workspace_id),
@@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::fs; use std::fs;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -788,31 +788,40 @@ mod tests {
.expect("home result"); .expect("home result");
assert_eq!(home["sourceKind"].as_str(), Some("local_folder")); assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown")); assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert!(home["tags"] assert!(
home["tags"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|tag| tag.as_str() == Some("alpha"))); .any(|tag| tag.as_str() == Some("alpha"))
assert!(home["backlinks"] );
assert!(
home["backlinks"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|link| link.as_str() == Some("Daily"))); .any(|link| link.as_str() == Some("Daily"))
assert!(home["resourceRefs"] );
assert!(
home["resourceRefs"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))); .any(|reference| reference.as_str() == Some("assets/spec.pdf"))
assert!(root );
.join(".mnote") assert!(
root.join(".mnote")
.join("index") .join("index")
.join("search-index.json") .join("search-index.json")
.exists()); .exists()
assert!(payload["recent"] );
assert!(
payload["recent"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .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); let _ = fs::remove_dir_all(&root);
} }
@@ -921,11 +930,13 @@ mod tests {
backlinks_payload["meta"]["queryName"].as_str(), backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks") Some("search.local_index.backlinks")
); );
assert!(backlinks_payload["result"]["backlinks"] assert!(
backlinks_payload["result"]["backlinks"]
.as_array() .as_array()
.unwrap() .unwrap()
.iter() .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() let tags_response = app()
.oneshot( .oneshot(
+5 -5
View File
@@ -1,10 +1,10 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::session_token_hash; use control_plane::session_token_hash;
use serde::Serialize; use serde::Serialize;
@@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; 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; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
@@ -2,12 +2,12 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::gateway::current_actor_id; use crate::routes::gateway::current_actor_id;
use axum::Json;
use axum::extract::{Extension, Path, Query, State}; use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput}; use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -2,11 +2,11 @@ use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ 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 bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind}; use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{json, Value}; use serde_json::{Value, json};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> { pub struct ProjectionSnapshotSpec<'a> {
@@ -38,7 +38,7 @@ pub async fn load_sidebar_dataset(
context: &RequestContext, context: &RequestContext,
workspace_id: &str, workspace_id: &str,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
fetch_query_data_via_convex( fetch_query_data_via_legacy_cloud(
config, config,
context, context,
Some(workspace_id), Some(workspace_id),
+6 -6
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::stream_support::{ use crate::routes::stream_support::{
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview, StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot,
StreamChangeKind, StreamSnapshotQuery, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
}; };
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream; use futures_util::stream;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
@@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; use crate::app::{AppConfig, AppState, build_app};
use crate::routes::stream_support::StreamSnapshotQuery; use crate::routes::stream_support::StreamSnapshotQuery;
use axum::body::{to_bytes, Body}; use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use std::time::Duration; use std::time::Duration;
use tokio::time::timeout; use tokio::time::timeout;
@@ -2,16 +2,16 @@ use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::{ 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::{ use crate::routes::snapshot_support::{
execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset,
ProjectionSnapshotSpec, subtree_query,
}; };
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save", "page.body.save",
@@ -627,7 +627,7 @@ pub async fn load_stream_overview(
let effective_workspace_id = let effective_workspace_id =
resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)? resolve_effective_workspace_id(context, query.workspace_id.as_deref(), true)?
.expect("workspace_required 已确保存在"); .expect("workspace_required 已确保存在");
let overview = execute_runtime_query_via_convex( let overview = execute_runtime_query_via_legacy_cloud(
config, config,
context, context,
Some(&effective_workspace_id), 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, config,
context, context,
Some(&effective_workspace_id), Some(&effective_workspace_id),
@@ -701,8 +701,9 @@ pub async fn load_stream_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor, delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_scope,
}; };
use serde_json::json; use serde_json::json;
+260 -73
View File
@@ -3,45 +3,45 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::command_support::{ use crate::routes::command_support::{
build_runtime_command_plan, build_tree_target, ensure_non_empty, ensure_sort_order, 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::{ use crate::routes::local_folder_source::{
ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state, LocalAccessMode, ensure_local_workspace_access_with_state,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot, ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort,
load_local_folder_page_tree_snapshot, local_folder_watch_revision, load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_workspace_id_from_root_uri, LocalAccessMode, local_folder_watch_revision, local_workspace_id_from_root_uri,
}; };
use crate::routes::query_support::{ 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::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot};
use crate::transport::convex::execute_convex_mutation_by_name; use crate::transport::convex::execute_retired_mutation_by_name;
use crate::tree_shell::filetree_renderer::{ 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::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{ 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::{ use crate::tree_shell::picker_renderer::{
render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow, PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html,
}; };
use crate::tree_shell::renderer_input::{ use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput, TreeShellRendererInput,
}; };
use crate::tree_shell::runtime_api::{ use crate::tree_shell::runtime_api::{
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest, TreeShellRuntimeRequest, TreeShellRuntimeResult,
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::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use bridge_runtime::RuntimeCommandEnvelopeWire; use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; 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)] #[derive(Debug)]
pub enum TreeCommandRequest { pub enum TreeCommandRequest {
Create { 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()) { if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) {
let parent_meta = 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 if let Some(workspace_id) = parent_meta
.get("workspace_id") .get("workspace_id")
.or_else(|| parent_meta.get("workspaceId")) .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(), state.config(),
context, context,
"workspaces:ensureDefaultWorkspace", "workspaces:ensureDefaultWorkspace",
@@ -2201,7 +2264,15 @@ pub async fn tree_command(
.with_context(&context) .with_context(&context)
.with_header("x-error-phase", "tree_command_decode") .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() { let request = match raw_request.action.trim() {
"create" => TreeCommandRequest::Create { "create" => TreeCommandRequest::Create {
workspace_id: raw_request.workspace_id, workspace_id: raw_request.workspace_id,
@@ -2453,7 +2524,7 @@ pub async fn tree_command(
command_wire.preflight_data = command_wire.preflight_data =
Some(load_tree_move_preflight_data(&state, &context, &effective_workspace_id).await?); 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, &state,
&context, &context,
Some(&effective_workspace_id), Some(&effective_workspace_id),
@@ -2513,10 +2584,10 @@ mod tests {
include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); include_str!("../../browser/tree-shell-filetree-dnd-runtime.js");
use super::{ use super::{
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest,
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::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan; use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body; use axum::body::Body;
@@ -2540,7 +2611,7 @@ mod tests {
convex_url: None, convex_url: None,
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, 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()), 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_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
@@ -2681,14 +2752,20 @@ mod tests {
assert!(html.contains("data-rust-action=\"toggle\"")); assert!(html.contains("data-rust-action=\"toggle\""));
assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(html.contains("data-testid=\"tree-node-toggle\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree"));
assert!(TREE_SHELL_RUNTIME_JS assert!(
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally"));
assert!(TREE_SHELL_RUNTIME_JS assert!(
.contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); TREE_SHELL_RUNTIME_JS
assert!(TREE_SHELL_RUNTIME_JS .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")
.contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); );
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("application/x-mnote-page-tree-node"));
assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到")); assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到"));
assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")")); assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")"));
@@ -2713,8 +2790,11 @@ mod tests {
.expect("body"); .expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8"); let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("id=\"tree-shell-state\"")); assert!(html.contains("id=\"tree-shell-state\""));
assert!(html assert!(
.contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\"")); html.contains(
"type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\""
)
);
assert!( assert!(
!html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"), !html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"),
"debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML" "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!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\""));
assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction"));
assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost")); assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost"));
assert!(TREE_SHELL_RUNTIME_JS assert!(
.contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); TREE_SHELL_RUNTIME_JS
assert!(TREE_SHELL_RUNTIME_JS .contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")
.contains("applyPickerFocusByItemKey(item.nodeId, { 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("const shouldFocusDom = options.focusDom === true"));
assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement")); assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement"));
assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom")); assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree"));
assert!(html.contains("tabindex=\"")); assert!(html.contains("tabindex=\""));
assert!(TREE_SHELL_RUNTIME_JS assert!(
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__"));
} }
@@ -2799,12 +2885,18 @@ mod tests {
assert!( assert!(
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)") TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
); );
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS assert!(
.contains("function buildFileTreeMenuTarget(context")); TREE_SHELL_FILETREE_MENU_RUNTIME_JS
assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS .contains("function buildFileTreeMenuTarget(context")
.contains("function createTreeShellFileTreeDndRuntime(context)")); );
assert!(TREE_SHELL_RENDER_RUNTIME_JS assert!(
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); TREE_SHELL_FILETREE_DND_RUNTIME_JS
.contains("function createTreeShellFileTreeDndRuntime(context)")
);
assert!(
TREE_SHELL_RENDER_RUNTIME_JS
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")
);
assert!( assert!(
TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))") 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("documentId: ownerDocumentId || null"));
assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover"));
assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree"));
assert!(TREE_SHELL_RUNTIME_JS assert!(
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); TREE_SHELL_RUNTIME_JS
.contains("const usedRustInitialRenderer = hydrateInitialRenderer();")
);
} }
#[tokio::test] #[tokio::test]
@@ -3026,8 +3120,8 @@ mod tests {
} }
#[tokio::test] #[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 = let root =
std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id())); std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&root);
@@ -3142,11 +3236,12 @@ mod tests {
String::from_utf8_lossy(&move_body) String::from_utf8_lossy(&move_body)
); );
assert!(!root.join("重命名页面").exists()); assert!(!root.join("重命名页面").exists());
assert!(root assert!(
.join("docs") root.join("docs")
.join("重命名页面") .join("重命名页面")
.join("重命名页面.md") .join("重命名页面.md")
.exists()); .exists()
);
let move_payload: Value = serde_json::from_slice(&move_body).expect("move json"); let move_payload: Value = serde_json::from_slice(&move_body).expect("move json");
let moved_document_id = move_payload["result"]["documentId"] let moved_document_id = move_payload["result"]["documentId"]
.as_str() .as_str()
@@ -3186,11 +3281,12 @@ mod tests {
.as_str() .as_str()
.expect("copied document id") .expect("copied document id")
.to_string(); .to_string();
assert!(root assert!(
.join("docs") root.join("docs")
.join("重命名页面 2") .join("重命名页面 2")
.join("重命名页面 2.md") .join("重命名页面 2.md")
.exists()); .exists()
);
let folder_response = app() let folder_response = app()
.oneshot( .oneshot(
@@ -3223,12 +3319,13 @@ mod tests {
.expect("response"); .expect("response");
assert_eq!(delete_response.status(), StatusCode::OK); assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root.join("docs").join("重命名页面").exists()); assert!(!root.join("docs").join("重命名页面").exists());
assert!(root assert!(
.join(".mnote") root.join(".mnote")
.join("trash") .join("trash")
.join("重命名页面") .join("重命名页面")
.join("重命名页面.md") .join("重命名页面.md")
.exists()); .exists()
);
assert!(root.join(".mnote").join("trash-index.json").exists()); assert!(root.join(".mnote").join("trash-index.json").exists());
assert!(!root.join(".mnote").join("page-ids.json").exists()); assert!(!root.join(".mnote").join("page-ids.json").exists());
@@ -3255,11 +3352,12 @@ mod tests {
"{}", "{}",
String::from_utf8_lossy(&restore_body) String::from_utf8_lossy(&restore_body)
); );
assert!(root assert!(
.join("docs") root.join("docs")
.join("重命名页面") .join("重命名页面")
.join("重命名页面.md") .join("重命名页面.md")
.exists()); .exists()
);
let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json"); let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json");
assert_eq!( assert_eq!(
restore_payload["result"]["documentId"].as_str(), restore_payload["result"]["documentId"].as_str(),
@@ -3353,7 +3451,21 @@ mod tests {
) )
.await .await
.expect("response"); .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("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists()); assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
@@ -3364,13 +3476,28 @@ mod tests {
.uri("/api/tree/commands") .uri("/api/tree/commands")
.header("content-type", "application/json") .header("content-type", "application/json")
.body(Body::from(format!( .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"), .expect("request"),
) )
.await .await
.expect("response"); .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() let purge_response = app()
.oneshot( .oneshot(
@@ -3385,7 +3512,21 @@ mod tests {
) )
.await .await
.expect("response"); .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("docs").join("photo.png").exists());
assert!(!root.join(".mnote").join("trash").join("photo.png").exists()); assert!(!root.join(".mnote").join("trash").join("photo.png").exists());
let trash_index_after = let trash_index_after =
@@ -3432,10 +3573,12 @@ mod tests {
assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(payload["ok"], false); assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "local_folder_root_escape"); assert_eq!(payload["code"], "local_folder_root_escape");
assert!(payload["message"] assert!(
payload["message"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.contains("root")); .contains("root")
);
assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0); assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0);
assert_eq!( assert_eq!(
headers headers
@@ -3604,8 +3747,10 @@ mod tests {
assert!(filetree_html.contains( assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
)); ));
assert!(filetree_html assert!(
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); filetree_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
assert!(filetree_html.contains( assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]" "\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
)); ));
@@ -3633,8 +3778,10 @@ mod tests {
assert!(picker_html.contains( assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
)); ));
assert!(picker_html assert!(
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); picker_html
.contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")
);
} }
#[tokio::test] #[tokio::test]
@@ -4050,11 +4197,13 @@ mod tests {
Some("convex://workspace/ws_demo") Some("convex://workspace/ws_demo")
); );
assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo")); assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo"));
assert!(create_wire assert!(
create_wire
.source .source
.capabilities .capabilities
.iter() .iter()
.any(|capability| capability == "execute-command")); .any(|capability| capability == "execute-command")
);
let rename_wire = create_command_wire( let rename_wire = create_command_wire(
&context, &context,
@@ -4242,4 +4391,42 @@ mod tests {
assert_eq!(tree_move_plan.function_name, "documents:move"); assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name); 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::error::WebError;
use crate::routes::gateway::current_actor_id; use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::local_workspace_id_from_root_uri; use crate::routes::local_folder_source::local_workspace_id_from_root_uri;
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{Value, json};
const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree"; const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree";
const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1"; const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1";
@@ -73,7 +73,11 @@ pub(crate) async fn get_tree_view_state(
)?; )?;
let preferences = state let preferences = state
.control_plane() .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| { .map_err(|error| {
WebError::internal(format!("SQLite tree view state 读取失败: {error}")) WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
.with_context(&context) .with_context(&context)
@@ -206,10 +210,7 @@ fn normalize_state(
scope: &TreeViewStateScope, scope: &TreeViewStateScope,
value: Value, value: Value,
) -> Result<Value, WebError> { ) -> Result<Value, WebError> {
let mut object = value let mut object = value.as_object().cloned().ok_or_else(|| {
.as_object()
.cloned()
.ok_or_else(|| {
WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象") WebError::bad_request_code("tree_view_state_invalid", "state 必须是对象")
.with_context(context) .with_context(context)
})?; })?;
@@ -247,7 +248,10 @@ fn normalize_state(
object.insert("scope".to_string(), json!(scope.scope)); object.insert("scope".to_string(), json!(scope.scope));
object.insert( object.insert(
"expandedIds".to_string(), "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( object.insert(
"expandedRelativePaths".to_string(), "expandedRelativePaths".to_string(),
@@ -261,11 +265,13 @@ fn normalize_state(
} }
object.insert( object.insert(
"scrollTop".to_string(), "scrollTop".to_string(),
json!(object json!(
object
.get("scrollTop") .get("scrollTop")
.and_then(Value::as_f64) .and_then(Value::as_f64)
.filter(|value| value.is_finite() && *value >= 0.0) .filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(0.0)), .unwrap_or(0.0)
),
); );
if !object.contains_key("updatedAtMs") { if !object.contains_key("updatedAtMs") {
object.insert("updatedAtMs".to_string(), json!(0)); object.insert("updatedAtMs".to_string(), json!(0));
@@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id;
use crate::routes::local_folder_source::{ use crate::routes::local_folder_source::{
local_root_has_workspace_manifest, local_workspace_id_from_root_uri, local_root_has_workspace_manifest, local_workspace_id_from_root_uri,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use serde_json::{json, Map}; use serde_json::{Map, json};
use std::collections::BTreeMap; use std::collections::BTreeMap;
pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space"; pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space";
@@ -58,9 +58,33 @@ struct PagePreferenceScope {
struct EffectivePagePreferences { struct EffectivePagePreferences {
scope: PagePreferenceScope, scope: PagePreferenceScope,
page_options: PageOptions, page_options: PageOptions,
page_width_preferences: BTreeMap<String, EffectivePageWidthPreference>,
ai_preferences: BTreeMap<String, Value>,
sources: BTreeMap<String, String>, 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( pub(crate) async fn effective_preferences(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -220,30 +244,57 @@ fn resolve_effective_page_preferences(
page_options.hide_title_header = true; page_options.hide_title_header = true;
} }
let mut sources = BTreeMap::new(); 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 { Ok(EffectivePagePreferences {
scope, scope,
page_options, page_options,
page_width_preferences,
ai_preferences,
sources, sources,
}) })
} }
fn apply_preference_records( fn apply_preference_records(
page_options: &mut PageOptions, page_options: &mut PageOptions,
page_width_preferences: &mut BTreeMap<String, EffectivePageWidthPreference>,
ai_preferences: &mut BTreeMap<String, Value>,
sources: &mut BTreeMap<String, String>, sources: &mut BTreeMap<String, String>,
scope: &PagePreferenceScope, scope: &PagePreferenceScope,
preferences: &[UserUiPreferenceRecord], preferences: &[UserUiPreferenceRecord],
) -> Result<(), WebError> { ) -> 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 for preference in preferences
.iter() .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", "global" => preference.scope_id.trim() == "default",
"source_family" => preference.scope_id.trim() == scope.source_family, "source_family" => preference.scope_id.trim() == scope.source_family,
"workspace" => preference.scope_id.trim() == scope.workspace_id, "workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id, "document" => preference.scope_id.trim() == scope.document_id,
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
_ => false, _ => false,
}; };
if !scope_matches { if !scope_matches {
@@ -255,6 +306,22 @@ fn apply_preference_records(
preference.key 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) { if apply_page_option_value(page_options, &preference.key, &value) {
sources.insert(preference.key.clone(), scope_kind.to_string()); 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)> { 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() { match key.trim() {
"hideTitleHeader" | "hide_title_header" => { "hideTitleHeader" | "hide_title_header" => {
Some(("source_family".to_string(), scope.source_family.clone())) 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> { 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()) Some(workspace_id.to_string())
} else { } else {
None 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> { 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()) Some(source_kind.to_string())
} else { } else {
None 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 { fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
let page_options = serde_json::to_value(&effective.page_options).unwrap_or_else(|_| json!({})); 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 let sources = effective
.sources .sources
.into_iter() .into_iter()
@@ -354,6 +438,8 @@ fn effective_preferences_payload(effective: EffectivePagePreferences) -> Value {
"documentId": effective.scope.document_id, "documentId": effective.scope.document_id,
}, },
"pageOptions": page_options, "pageOptions": page_options,
"pageWidthPreferences": page_width_preferences,
"aiPreferences": ai_preferences,
"sources": Value::Object(sources), "sources": Value::Object(sources),
} }
}) })
@@ -474,6 +560,127 @@ fn apply_page_option_value(options: &mut PageOptions, key: &str, value: &Value)
false 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 { trait EmptyStringExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String; 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::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::stream_support::{ 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::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::response::Response; use axum::response::Response;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde_json::{json, Value}; use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
pub async fn socket( pub async fn socket(
@@ -27,6 +27,9 @@ pub fn HomePage(
/// 当前选中的页面标题(可选) /// 当前选中的页面标题(可选)
#[prop(optional)] #[prop(optional)]
active_page_title: Option<String>, active_page_title: Option<String>,
/// 导航页主体 HTML(可选)
#[prop(optional)]
navigation_html: String,
/// 是否显示管理员授权入口 /// 是否显示管理员授权入口
#[prop(optional)] #[prop(optional)]
show_admin_access_policy: bool, show_admin_access_policy: bool,
@@ -37,6 +40,9 @@ pub fn HomePage(
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人".to_string()); .unwrap_or_else(|| "个人".to_string());
let has_active_page = !active_page_id.trim().is_empty(); 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 let workspace_id = workspace_id
.map(|value| value.trim().to_string()) .map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
@@ -63,6 +69,57 @@ pub fn HomePage(
</section> </section>
</main> </main>
}.into_any() }.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 { } else {
let empty_primary_model = DocumentPaneViewModel { let empty_primary_model = DocumentPaneViewModel {
pane_role: "primary", pane_role: "primary",
+58 -9
View File
@@ -94,7 +94,7 @@ pub fn PageLayout(
let tree_live_bootstrap = serde_json::json!({ let tree_live_bootstrap = serde_json::json!({
"schema": "mnote.tree_live_bootstrap.v1", "schema": "mnote.tree_live_bootstrap.v1",
"disabled": !enable_tree_live, "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, "workspaceId": null,
"rootIds": [], "rootIds": [],
"initialRevision": null, "initialRevision": null,
@@ -356,15 +356,18 @@ mod tests {
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
.contains("mnote.sidebarTreeViewState.v1:{userId}:{workspaceId}:{sourceKind}:{treeKind}:{rootUriHash}:{scopeHash}")); .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("sidebarTreeViewStateFor('pagetree'"));
assert!(render_page_rows.contains("expandedIds")); assert!(render_page_rows.contains("expandedIds"));
assert!( assert!(
!render_page_rows.contains("var expanded = expandable && item.expandedByDefault !== false"), !render_page_rows
.contains("var expanded = expandable && item.expandedByDefault !== false"),
"PageTree 不能只依赖 projection expandedByDefault 决定展开" "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("sidebarTreeViewStateFor('filetree'"));
assert!(render_file_rows.contains("expandedRelativePaths")); 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("/api/sidebar/shortcuts"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("toggle-sidebar-shortcut"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("openStarredFolderShortcut")); 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("rootUri: currentRootUri()"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var rootUri = readShortcutRootUri(row);"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("inferLocalRootUriFromWorkspaceId")); 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] #[test]
fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() { fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() {
assert!( assert!(
@@ -655,6 +675,10 @@ mod tests {
injection.contains("closestAction"), injection.contains("closestAction"),
"attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError" "attachment open runtime 需要显式注入 closestAction,避免首屏点击监听安装时 ReferenceError"
); );
assert!(
injection.contains("hydrateEditorAttachmentMeta"),
"attachment open runtime 需要显式注入 hydrateEditorAttachmentMeta,避免上传附件增强时 ReferenceError"
);
assert!( assert!(
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
.contains("closestAction = typeof injectedClosestAction === 'function'") .contains("closestAction = typeof injectedClosestAction === 'function'")
@@ -1412,9 +1436,16 @@ mod tests {
#[test] #[test]
fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() { fn sidebar_filetree_context_evaluator_uses_readonly_and_selection_count_for_delete_key() {
assert!( assert!(
SIDEBAR_TREE_RUNTIME_JS SIDEBAR_TREE_RUNTIME_JS.contains(
.contains("evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')"), "evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')"
"Delete/Backspace 快捷键应至少评估 workspace.readonly 上下文" ),
"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/")); 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] #[test]
fn sidebar_filetree_asset_open_uses_owner_document_id() { fn sidebar_filetree_asset_open_uses_owner_document_id() {
assert!( assert!(
@@ -1520,6 +1557,18 @@ mod tests {
#[test] #[test]
fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() { fn sidebar_tree_runtime_opens_pdf_and_code_assets_with_builtin_tools() {
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openPdfEditorAttachment")); 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 openCodeEditorAttachment"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl")); assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
@@ -1553,8 +1602,8 @@ mod tests {
#[test] #[test]
fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() { 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("tree-live-sse"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-ws")); 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("new WebSocket(url.toString())"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback")); assert!(TREE_LIVE_CONTROLLER_JS.contains("startWithSseFallback"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport")); 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-active="true"],
.sidebar-tree .tree-row[data-selected="true"] { .sidebar-tree .tree-row[data-selected="true"] {
background: rgba(255, 71, 71, 0.1); background: rgba(55, 53, 47, 0.08);
color: #E0525B; color: #2D2E2E;
} }
.sidebar-tree .tree-row[data-drop-feedback="true"], .sidebar-tree .tree-row[data-drop-feedback="true"],
@@ -1676,26 +1676,30 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
line-height: 1; line-height: 1;
font-size: 0;
} }
.sidebar-tree .tree-toggle:hover { .sidebar-tree .tree-toggle:hover {
background: rgba(27, 28, 28, 0.06); background: rgba(27, 28, 28, 0.06);
} }
.sidebar-tree .tree-toggle-icon { .sidebar-tree .tree-toggle::before {
width: 20px; content: "";
height: 20px; width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
display: block; display: block;
fill: #878787;
transform-origin: 50% 50%; 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); transform: rotate(90deg);
} }
.sidebar-tree .tree-row[data-active="true"] .tree-toggle-icon { .sidebar-tree .tree-row[data-active="true"] .tree-toggle {
fill: #CF5659; color: #8F8A84;
} }
.sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] { .sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] {
@@ -1703,58 +1707,137 @@ body {
} }
.sidebar-tree .tree-kind-badge { .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; width: 20px;
height: 20px; height: 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 0 0 20px; flex: 0 0 20px;
color: #111111; color: #9A958F;
font-size: 15px;
overflow: hidden; overflow: hidden;
} }
.sidebar-tree .tree-kind-badge::before { .sidebar-tree .tree-kind-badge::before {
content: ""; content: "";
font-family: var(--wolai-font-sans); width: 14px;
font-size: 18px; height: 14px;
line-height: 1; 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 { .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[data-tree-shell-mode="filetree"] .tree-kind-badge[data-kind="page"]::before,
.sidebar-tree .tree-kind-badge[data-kind="document"]::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 { .sidebar-tree .tree-kind-badge[data-kind="index"]::before,
content: ""; .sidebar-tree .tree-kind-badge[data-kind="markdown"]::before {
color: var(--atelier-secondary); --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="folder"]::before,
.sidebar-tree .tree-kind-badge[data-kind="asset_folder"]::before { .sidebar-tree .tree-kind-badge[data-kind="asset_folder"]::before {
content: ""; --mnote-filetree-icon-mask: var(--mnote-filetree-icon-folder);
color: #D08A1F; }
.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 { .sidebar-tree .tree-kind-badge[data-kind="image"]::before {
content: ""; --mnote-filetree-icon-mask: var(--mnote-filetree-icon-image);
color: #5B7CFA; }
.sidebar-tree .tree-kind-badge[data-kind="mindmap"] {
color: #4F8F68;
} }
.sidebar-tree .tree-kind-badge[data-kind="mindmap"]::before { .sidebar-tree .tree-kind-badge[data-kind="mindmap"]::before {
content: ""; --mnote-filetree-icon-mask: var(--mnote-filetree-icon-mindmap);
color: #31AA4D;
} }
.sidebar-tree .tree-kind-badge[data-kind="table"]::before, .sidebar-tree .tree-kind-badge[data-kind="pdf"]::before {
.sidebar-tree .tree-kind-badge[data-kind="luckysheet"]::before { --mnote-filetree-icon-mask: var(--mnote-filetree-icon-pdf);
content: ""; }
color: #2367F6;
.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 { .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); 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 { .document-main-editor-group {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -4236,6 +4406,20 @@ button.wolai-page-ai-message-text {
background: #FFF; 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 { .wolai-page-ai-composer:focus-within {
border-color: rgba(27, 28, 28, 0.32); border-color: rgba(27, 28, 28, 0.32);
} }
@@ -4515,11 +4699,62 @@ mod tests {
assert!(MNOTE_CSS.contains(".mnote-tree-context-menu")); 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] #[test]
fn mnote_css_is_reasonably_sized() { fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式 // 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000); assert!(MNOTE_CSS.len() > 2000);
// 当前整合了工作区壳、编辑器样式、树菜单、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。 // 当前整合了工作区壳、编辑器样式、树菜单、文件树图标、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
assert!(MNOTE_CSS.len() < 110000); 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