diff --git a/harness-init.sh b/harness-init.sh index 191fae5c..5cf8b68e 100644 --- a/harness-init.sh +++ b/harness-init.sh @@ -15,13 +15,8 @@ command -v python3 >/dev/null || { exit 1 } -if [ -d "$ROOT_DIR/wolai-frontend" ]; then - if ! command -v node >/dev/null; then - echo "WARN: 缺少 node,前端相关任务可能无法执行" - fi - if ! command -v pnpm >/dev/null; then - echo "WARN: 缺少 pnpm,前端相关任务可能无法执行" - fi +if [ -f "$ROOT_DIR/package.json" ] && ! command -v node >/dev/null; then + echo "WARN: 缺少 node,脚本任务可能无法执行" fi echo "Harness 环境检查完成" diff --git a/harness-tasks.json b/harness-tasks.json deleted file mode 100644 index c40d3e47..00000000 --- a/harness-tasks.json +++ /dev/null @@ -1,1114 +0,0 @@ -{ - "version": 2, - "created": "2026-04-26T23:16:13Z", - "session_config": { - "concurrency_mode": "exclusive", - "max_tasks_per_session": 9, - "max_sessions": 80 - }, - "tasks": [ - { - "id": "task-001", - "title": "Phase K-1:定义 tree shell Rust runtime artifact 边界", - "status": "completed", - "priority": "P0", - "depends_on": [], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web renderer_input && cargo test -p mnote-web runtime_artifact && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx", - "timeout_seconds": 1800 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已在 mnote-web rendererInput 中定义 rust_tree_shell_runtime_artifact_v1,覆盖 rendererInput/projection/expanded/selected/active/focused 输入、DOM patch/intent/command 输出和 focus/keyboard/expand/selection/context/drag/pick 事件边界;3000 inline host 同步输出同名 runtimeArtifact。", - "timestamp": "2026-04-26T23:22:39Z" - } - ], - "completed_at": "2026-04-26T23:22:39Z" - }, - { - "id": "task-002", - "title": "Phase K-2:建立 page tree Rust-side runtime 状态机", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-001" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_renderer && cargo test -p mnote-web page_focus && cargo test -p mnote-web page_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 page_runtime reducer,组合 focus/expand/open/context-menu/drop feedback/tree.subtree.move dispatch 输出,验证 page_renderer/page_focus/page_runtime、tree-shell iframe/surface vitest 与 task112 smoke 均通过。", - "timestamp": "2026-04-26T23:27:43Z" - } - ], - "completed_at": "2026-04-26T23:27:43Z" - }, - { - "id": "task-003", - "title": "Phase K-3:建立 file tree Rust-side runtime 状态机", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-001" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web filetree_renderer && cargo test -p mnote-web filetree_selection && cargo test -p mnote-web filetree_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/lib/file-tree/shell.test.ts src/lib/tree-stream/tree-delta.test.ts && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 filetree_runtime reducer,覆盖 selection/range/context/normalize、drag row ids、copy/move effect、drop target feedback,以及 doc/index/asset-folder/asset 的 open/context-menu intent;目标测试与 task112 smoke 通过。", - "timestamp": "2026-04-26T23:32:11Z" - } - ], - "completed_at": "2026-04-26T23:32:11Z" - }, - { - "id": "task-004", - "title": "Phase K-4:建立 picker Rust-side runtime 状态机", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-001" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web picker_renderer && cargo test -p mnote-web picker_state && cargo test -p mnote-web picker_runtime && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/documents/move-embed-picker-dialog.test.tsx src/components/sidebar/tree-shell-iframe-host.test.tsx && node /mnt/Data1T/mnote/scripts/task113-picker-keyboard-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 picker_runtime reducer,覆盖 normalize/next/previous/home/end/focus/pick、excluded ids、root pick,以及搜索输入不被键盘运行时抢 DOM 焦点的 focus_dom 输出边界;目标测试与 task113 smoke 通过。", - "timestamp": "2026-04-26T23:34:59Z" - } - ], - "completed_at": "2026-04-26T23:34:59Z" - }, - { - "id": "task-005", - "title": "Phase K-5:3000 host 调用正式 runtime artifact,并将 mnote-web /tree 固定为 debug/internal", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-002", - "task-003", - "task-004" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js && node /mnt/Data1T/mnote/scripts/task113-picker-keyboard-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已固定 3000 inline host 与 mnote-web debug shell 均输出 rust_tree_shell_runtime_artifact_v1,并通过 tree-shell 目标测试、task112 与 task113 证明 3000 主路径继续使用 inline iframe host,/api/tree/shell debug 直连默认禁用。", - "timestamp": "2026-04-26T23:38:55Z" - } - ], - "completed_at": "2026-04-26T23:38:55Z" - }, - { - "id": "task-006", - "title": "Phase L:将 tree.subtree.move 最终写路径继续下沉 Rust kernel", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-005" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime tree_subtree_move_command && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/documents/tree-command-client.test.ts src/lib/tree-stream/tree-delta.test.ts", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已引入 Rust treeWriteOperation,Convex documents.move 优先执行 Rust write operation,normalizedMove 保留旧 fallback;bridge-runtime move 单测与前端 39 个目标测试通过。", - "timestamp": "2026-04-26T23:52:52Z" - } - ], - "completed_at": "2026-04-26T23:52:52Z" - }, - { - "id": "task-007", - "title": "Phase M:补齐 block/save-snapshot formal artifact 与 domain-event contract", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-006" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract && cargo test -p bridge-runtime block_commands_include_formal_domain_event_contract && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/page-write-command-adapter.test.ts src/lib/blocks/block-command-adapter.test.ts", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已将 page.body.saved、block.patched、block.moved、block.embedded 切到 formal domainEventPlan payload schema;复合命令 streamDelta 使用 resync_required 保守策略,command log/domain event 通过同一 command_id 关联;目标验证通过。", - "timestamp": "2026-04-27T00:09:12Z" - } - ], - "completed_at": "2026-04-27T00:09:12Z" - }, - { - "id": "task-008", - "title": "Phase N:继续收薄 3000 Next route 过渡职责", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-007" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-stream/tree-delta.test.ts && node /mnt/Data1T/mnote/scripts/task112-tree-rust-family-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 2, - "total": 2, - "description": "Phase N route thinning manifest 与 task112 iframe-ready smoke 同步已完成;刷新本地 Convex functions 后 move treeWriteOperation 运行态验证通过,目标 Vitest 与 task112 smoke 均通过。", - "timestamp": "2026-04-27T00:34:43Z" - } - ], - "completed_at": "2026-04-27T00:34:43Z" - }, - { - "id": "task-009", - "title": "Phase O:搜索语义 hardening 与资源类型 fixture 补齐", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-008" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "6d89f5ca", - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-stream/tree-delta.test.ts", - "timeout_seconds": 1800 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已补 file_tree 搜索 hardening 前端契约:projection-client 固定 maxResults=80、祖先补全/排序/异步可见性元数据,tree-delta fixture 覆盖 index、asset-folder、mindmap child、book、pdf;目标验证与 task112 smoke 通过。", - "timestamp": "2026-04-27T00:41:58Z" - } - ], - "completed_at": "2026-04-27T00:41:58Z" - }, - { - "id": "task-010", - "title": "Phase P-0:补 final DOM shell 负向门禁,阻止 iframe_srcdoc 被误判为完成", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-009" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", - "scope": [ - "wolai-frontend/src/components/sidebar/tree-shell-host*.test.tsx", - "wolai-frontend/src/components/sidebar/tree-shell-iframe-host.test.tsx", - "scripts/task112-tree-rust-family-regression-smoke.js", - "scripts/task113-picker-keyboard-regression-smoke.js" - ], - "acceptance": [ - "默认 page/filetree/picker 主路径测试不得再把 srcDoc inline DOM shell 作为成功条件", - "默认主路径测试不得出现 data-tree-browser-bridge=\"iframe_srcdoc\" 正向断言", - "旧 TreeShellIframeHost 的 srcdoc 合同测试必须迁入 legacy/debug 分组或显式标注为 legacy", - "task112/task113 smoke 需要验证新 Rust/WASM DOM shell 主路径,而不是证明旧 inline iframe host 继续可用" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 4, - "description": "已新增默认非 iframe rust_wasm_dom_shell_host 与共享 DOM projection model,legacy iframe 改为显式环境开关。", - "timestamp": "2026-04-28T03:28:16.005752Z" - }, - { - "step": 2, - "total": 4, - "description": "tree-shell-surface 与 legacy iframe host 目标 Vitest 通过;默认主路径断言已切成 rust_wasm_dom_shell_host 且负向排除 iframe_srcdoc。", - "timestamp": "2026-04-28T03:34:10.533972Z" - }, - { - "step": 3, - "total": 4, - "description": "move/embed picker 测试已切到 DOM shell;默认 picker 不再以 iframe postMessage 为成功条件,目标前端 Vitest 29 项通过。", - "timestamp": "2026-04-28T03:36:24.605315Z" - }, - { - "step": 4, - "total": 4, - "description": "已补默认 DOM shell 负向门禁:tree-shell-host/surface/picker 测试与 smoke 默认拒绝 iframe_srcdoc 成功路径。", - "timestamp": "2026-04-28T04:40:14Z" - } - ], - "completed_at": "2026-04-28T04:40:14Z" - }, - { - "id": "task-011", - "title": "Phase P-1:拆分默认 Rust DOM shell host 与 legacy TreeShellIframeHost", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-010" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", - "scope": [ - "wolai-frontend/src/components/sidebar/tree-shell-host.tsx", - "wolai-frontend/src/components/sidebar/tree-shell-iframe-host.tsx", - "wolai-frontend/src/components/sidebar/tree-shell-*.test.tsx" - ], - "acceptance": [ - "TreeShellHost 默认选择 rust_wasm_dom_shell_host 或等价新 host", - "TreeShellIframeHost 改为 LegacyTreeShellIframeHost 或显式 legacy/debug host", - "旧 iframe srcdoc host 只能通过 debug/legacy flag 进入", - "默认 page tree 首屏不依赖 LOCAL_TREE_SHELL_TEMPLATE" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx src/components/sidebar/tree-shell-iframe-host.test.tsx", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "TreeShellHost 默认选择 rust_wasm_dom_shell_host;TreeShellIframeHost 仅由 NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1 进入。", - "timestamp": "2026-04-28T04:40:14Z" - } - ], - "completed_at": "2026-04-28T04:40:14Z" - }, - { - "id": "task-012", - "title": "Phase Q:page / picker / filetree 迁入 Rust/WASM DOM shell 主路径", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-011" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", - "scope": [ - "rust/crates/tree-shell-runtime-wasm", - "rust/crates/mnote-web/src/tree_shell", - "wolai-frontend/src/components/sidebar" - ], - "acceptance": [ - "page tree 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件", - "picker 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件", - "filetree 默认主路径由新 Rust/WASM DOM shell 渲染与绑定事件,并保留 doc/index/asset-folder/asset 能力", - "3000 host 只保留挂载、auth、transport、文件读取、postMessage/command bridge" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_shell && cargo test -p tree-shell-runtime-wasm -- --nocapture && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx src/components/documents/move-embed-picker-dialog.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js", - "timeout_seconds": 3600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "page / filetree / picker 默认 DOM host 已消费 TreeShellRuntimeRequest/Result,并由 WASM artifact 或同源 Rust seam 驱动 hostEvents/commandEvents。", - "timestamp": "2026-04-28T04:40:14Z" - } - ], - "completed_at": "2026-04-28T04:40:14Z" - }, - { - "id": "task-013", - "title": "Phase R/S:删除默认 JS DOM state machine 并完成文档收口", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-012" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", - "scope": [ - "wolai-frontend/src/components/sidebar", - "design/04-tree-domain/process", - "design/04-tree-domain/done" - ], - "acceptance": [ - "默认主路径不再调用 LOCAL_TREE_SHELL_TEMPLATE、buildInlinePageTreeHtml、buildInlineFileTreeHtml、buildInlinePickerHtml", - "默认主路径不再标注 data-tree-browser-bridge=\"iframe_srcdoc\"", - "旧 JS fallback reducer/state machine 只在 legacy/debug host 中存在", - "4-11、4-16、4-18 的 final DOM shell gate 按真实代码完成状态更新" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/sidebar/tree-shell-iframe-host.test.tsx src/components/sidebar/tree-shell-surface.test.tsx src/components/documents/move-embed-picker-dialog.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js && node scripts/task113-picker-keyboard-regression-smoke.js && git diff --check -- wolai-frontend/src/components/sidebar design/04-tree-domain", - "timeout_seconds": 3600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已清理 1.md、4-11、4-16、4-18 的 final DOM shell 口径;旧 JS renderer/state machine 只作为 legacy iframe host 保留。", - "timestamp": "2026-04-28T04:40:14Z" - } - ], - "completed_at": "2026-04-28T04:40:14Z" - }, - { - "id": "task-014", - "title": "Phase L2:删除 normalizedMove fallback,强制 move 主写路径使用 treeWriteOperation", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-013" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "scope": [ - "rust/crates/bridge-runtime/src/lib.rs", - "wolai-frontend/convex/documents.ts", - "wolai-frontend/convex/_utils/documentMoveOrder.ts", - "wolai-frontend/src/lib/documents/rust-runtime.ts", - "wolai-frontend/src/lib/documents/*move*test.ts", - "wolai-frontend/src/lib/documents/rust-runtime.test.ts" - ], - "acceptance": [ - "bridge-runtime 不再输出 normalizedMove 作为 documents.move 参数", - "Convex documents.move 不再接受 normalizedMove fallback;缺少 treeWriteOperation 时显式拒绝", - "TS Rust mutation transport 不再透传 normalizedMove", - "相关测试改为验证 treeWriteOperation 是唯一主写入计划" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime tree_subtree_move_command && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/document-move-order.test.ts src/lib/documents/document-move-order-convex.test.ts src/lib/documents/rust-runtime.test.ts src/app/api/tree/commands/route.test.ts", - "timeout_seconds": 3000 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已删除 normalizedMove fallback:Rust plan 不再输出 normalizedMove,TS transport 强制 treeWriteOperation,Convex documents.move 只接受 treeWriteOperation;目标 Rust/Vitest 通过。", - "timestamp": "2026-04-28T06:37:40Z" - } - ], - "completed_at": "2026-04-28T06:37:40Z" - }, - { - "id": "task-015", - "title": "Phase M2:定稿 document.snapshot.saved 独立事件并避免与 page.body.saved payload 重叠", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-014" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "scope": [ - "rust/crates/bridge-runtime/src/lib.rs", - "wolai-frontend/src/lib/documents/rust-runtime.ts", - "wolai-frontend/src/lib/documents/bridge-log.ts", - "wolai-frontend/src/lib/documents/*test.ts" - ], - "acceptance": [ - "page.body.saved 保留正文保存事件语义", - "snapshot 版本/hash/updatedAt 拆入 document.snapshot.saved 独立 domain event plan 或 artifact plan", - "SSE 对 snapshot 独立事件有明确 resync 或 noop 策略", - "测试覆盖两个事件不再 payload 重叠" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_save_command_plans_include_formal_domain_event_contract && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/documents/rust-runtime.test.ts src/lib/documents/bridge-log.test.ts src/lib/documents/page-write-command-adapter.test.ts", - "timeout_seconds": 3000 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已将 page.body.saved 与 document.snapshot.saved 拆成 domainEventPlans 双事件;artifact writer 支持 domainEvents 批量落库,page.body.saved payload 不再夹带 snapshot。", - "timestamp": "2026-04-28T07:44:37Z" - } - ], - "completed_at": "2026-04-28T07:44:37Z" - }, - { - "id": "task-016", - "title": "Phase M3:把 snapshot 独立事件接入 Page Aggregate 深层收口文档与客户端边界", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-015" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "scope": [ - "wolai-frontend/src/components/editor/page-aggregate*", - "wolai-frontend/src/lib/documents/page-aggregate*", - "design/05-editor-mainline/process", - "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md" - ], - "acceptance": [ - "Page Aggregate 文档明确 page.body.saved 与 document.snapshot.saved 的职责边界", - "客户端 Page Aggregate 状态不再把 snapshot 保存当作正文保存的附带语义", - "4-16 对应 snapshot/Page Aggregate 项可勾选或明确剩余迁移边界" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/page-aggregate-client-state.test.ts src/lib/documents/page-aggregate-builder.test.ts src/lib/documents/page-write-command-adapter.test.ts && git diff --check -- design/04-tree-domain design/05-editor-mainline wolai-frontend/src/components/editor wolai-frontend/src/lib/documents", - "timeout_seconds": 2400 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已更新 Page Aggregate contract 与 5-6 清单,固定 page.body.saved 与 document.snapshot.saved 的职责边界,并同步 4-16 勾选状态。", - "timestamp": "2026-04-28T07:44:37Z" - } - ], - "completed_at": "2026-04-28T07:44:37Z" - }, - { - "id": "task-017", - "title": "Phase N2:将 tree.node.embed 的 pageReference 组装迁到 Page Aggregate / Rust artifact 边界", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-015" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "scope": [ - "rust/crates/bridge-runtime/src/lib.rs", - "wolai-frontend/src/app/api/tree/commands/route.ts", - "wolai-frontend/src/lib/documents/page-command-adapter.ts", - "wolai-frontend/src/lib/tree-route-boundary.ts", - "wolai-frontend/src/app/api/tree/commands/route.test.ts" - ], - "acceptance": [ - "3000 route 不再本地决定 pageReference block 的结构与插入位置语义", - "Rust plan 或 Page Aggregate artifact 接收 source/target/anchor/content snapshot 并产出 next content 或 block patch plan", - "tree-route-boundary manifest 移除该 compatPending 项", - "测试覆盖 tree.node.embed 的 pageReference 组装来源已迁移" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime documents_embed_command_plan_maps_to_documents_update_content tree_embed_and_copy_aliases_keep_tree_command_names && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/commands/route.test.ts src/lib/tree-route-boundary.test.ts src/lib/documents/rust-runtime.test.ts", - "timeout_seconds": 3000 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 Rust pageAggregateEmbedPlan;3000 route 与 documents.embed adapter 只传 source/target/anchor/content snapshot preflight,不再本地决定 pageReference block 结构与插入位置。", - "timestamp": "2026-04-28T07:44:37Z" - } - ], - "completed_at": "2026-04-28T07:44:37Z" - }, - { - "id": "task-018", - "title": "Phase O2:补 file_tree 搜索索引可见性指标边界并收口 4-16", - "status": "completed", - "priority": "P2", - "depends_on": [ - "task-014" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "81ba21c5", - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "scope": [ - "rust/crates/bridge-runtime/src/lib.rs", - "rust/crates/mnote-web/src/routes/kernel.rs", - "wolai-frontend/src/lib/server/kernel-file-tree.ts", - "wolai-frontend/src/app/api/tree/projections/file/route.ts", - "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md" - ], - "acceptance": [ - "file_tree 搜索 projection 输出稳定 indexing/visibility meta,不再只是临时 request meta", - "3000 同源 projection route 透传该 meta", - "move-embed picker/file tree 搜索可观察 index visibility 状态", - "4-16 中 Phase O 未完成项完成或明确转为非树域 runtime 后续" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime file_tree && cargo test -p mnote-web file_tree_projection && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/tree/projections/file/route.test.ts src/lib/tree-stream/tree-delta.test.ts", - "timeout_seconds": 3000 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已把 file_tree 搜索 indexingVisibility 提升为 projection result meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 Phase O 已收口。", - "timestamp": "2026-04-28T07:44:37Z" - } - ], - "completed_at": "2026-04-28T07:44:37Z" - }, - { - "id": "task-019", - "title": "Phase W-0:冻结 Rust Web 主执行面 owner manifest 与负向门禁", - "status": "completed", - "priority": "P0", - "depends_on": [], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "wolai-frontend/src/lib/rust-web-main-execution-boundary.ts", - "wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts", - "ARCHITECTURE.md", - "design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md" - ], - "acceptance": [ - "新增 RUST_WEB_MAIN_EXECUTION_BOUNDARY,固定 publicEntry=3000、mainWebOwner=mnote-web、legacyCompatOwner=next-app-router", - "测试禁止把 Next App Router 写回主 Web owner", - "ARCHITECTURE.md 明确树域完成不等于主 Web 执行面完成" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts && cd /mnt/Data1T/mnote && git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib", - "timeout_seconds": 1800 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 Rust Web 主执行面 owner manifest 与负向门禁,并更新 ARCHITECTURE.md / 3-6 checklist 口径。", - "timestamp": "2026-04-28T09:07:58Z" - } - ], - "completed_at": "2026-04-28T09:07:58Z" - }, - { - "id": "task-020", - "title": "Phase W-1:让 mnote-web 默认成为 3000 gateway owner", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-019" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/app.rs", - "rust/crates/mnote-web/src/routes/mod.rs", - "rust/crates/mnote-web/src/routes/gateway.rs", - "scripts/desktop-hot.js", - "scripts/desktop-hot.test.js", - "scripts/task114-rust-web-gateway-entry-smoke.js" - ], - "acceptance": [ - "mnote-web 支持 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 并作为默认 3000 owner", - "Next dev 默认改为 legacy upstream port,不再拥有 3000", - "task114 smoke 证明 3000 响应来自 Rust gateway 且 3104 不作为公开入口" - ], - "validation": { - "command": "node --test /mnt/Data1T/mnote/scripts/desktop-hot.test.js && cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cd /mnt/Data1T/mnote && node scripts/task114-rust-web-gateway-entry-smoke.js", - "timeout_seconds": 3600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 mnote-web gateway route/owner header、desktop-hot Rust gateway 默认启动计划与 task114 smoke。", - "timestamp": "2026-04-28T09:13:44Z" - } - ], - "completed_at": "2026-04-28T09:13:44Z" - }, - { - "id": "task-021", - "title": "Phase W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-020" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/routes/session.rs", - "rust/crates/mnote-web/src/routes/mod.rs", - "wolai-frontend/src/lib/runtime-config.ts", - "wolai-frontend/src/lib/runtime-config.test.ts", - "wolai-frontend/src/app/api/auth/mnote-web-token/route.ts" - ], - "acceptance": [ - "mnote-web 输出 /api/runtime/config、/api/auth/session 与 session refresh/handoff route", - "浏览器 runtime config 不泄漏 internal MNOTE_WEB_* base URL", - "Next /api/auth/mnote-web-token 降级为 compat/debug" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web session && cargo test -p mnote-web runtime_config && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/runtime-config.test.ts src/app/api/auth/mnote-web-token/route.test.ts", - "timeout_seconds": 3000 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 mnote-web runtime config/session/refresh routes,并将 Next mnote-web-token route 标记为 compat/debug。", - "timestamp": "2026-04-28T09:16:28Z" - } - ], - "completed_at": "2026-04-28T09:16:28Z" - }, - { - "id": "task-022", - "title": "Phase W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-021" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/routes/web_shell.rs", - "rust/crates/mnote-web/src/routes/documents.rs", - "rust/crates/mnote-web/src/routes/mod.rs", - "wolai-frontend/src/components/editor/document-shell.tsx", - "wolai-frontend/src/components/editor/document-content.tsx", - "scripts/task115-rust-web-document-shell-smoke.js" - ], - "acceptance": [ - "mnote-web 输出 /documents/:id server-first shell 与 page aggregate snapshot", - "Next 文档页降级为 legacy compat", - "task115 smoke 证明默认文档页由 Rust shell 服务且不挂载 mnote-web-document-shell iframe" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web document_shell && cargo test -p mnote-web page_aggregate && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/document-content.test.ts src/lib/documents/page-aggregate-builder.test.ts && cd /mnt/Data1T/mnote && node scripts/task115-rust-web-document-shell-smoke.js", - "timeout_seconds": 3600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已新增 mnote-web /documents/:id shell 与 /api/page-aggregate/:id snapshot contract,并补 task115 smoke。", - "timestamp": "2026-04-28T09:20:22Z" - } - ], - "completed_at": "2026-04-28T09:20:22Z" - }, - { - "id": "task-023", - "title": "Phase W-4:把主 API transport 从 Next route 收薄为 Rust Web owned", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-022" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/routes/documents.rs", - "rust/crates/mnote-web/src/routes/tree.rs", - "rust/crates/mnote-web/src/routes/search.rs", - "wolai-frontend/src/lib/tree-route-boundary.ts", - "wolai-frontend/src/app/api/documents/route-adapters.test.ts", - "wolai-frontend/src/lib/search/request.test.ts" - ], - "acceptance": [ - "Rust Web owner 覆盖 documents meta/content/save、tree commands/projections 与 search documents transport", - "Next route 只保留 cookie/header 透传和 legacy compat", - "TREE_3000_ROUTE_BOUNDARY_MANIFEST owner 口径改为 rust-web-gateway" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web documents_api && cargo test -p mnote-web tree_commands && cargo test -p mnote-web search && cargo test -p bridge-runtime page_aggregate && cargo test -p bridge-runtime tree_subtree_move_command && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-route-boundary.test.ts src/app/api/documents/route-adapters.test.ts src/lib/search/request.test.ts", - "timeout_seconds": 3600 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [ - "[CONFIG] 原验证中的 cargo test -p bridge-runtime page_aggregate 过滤词当前命中 0 项;本任务实际覆盖 documents_api/tree_commands/search 与 route boundary。" - ], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "已补 Rust Web search.documents transport、documents owner headers 与 TREE_3000_ROUTE_BOUNDARY rust-web-gateway owner 口径。", - "timestamp": "2026-04-28T09:26:14Z" - } - ], - "completed_at": "2026-04-28T09:26:14Z" - }, - { - "id": "task-024", - "title": "Phase W-5:把 tree realtime stream 固定为 Rust Web 主链", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-023" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/routes/sse.rs", - "rust/crates/mnote-web/src/routes/stream_support.rs", - "wolai-frontend/src/app/api/mnote-web/stream/route.ts", - "wolai-frontend/src/lib/tree-stream/protocol.ts", - "wolai-frontend/src/lib/tree-stream/tree-stream.test.ts", - "wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.test.tsx" - ], - "acceptance": [ - "Rust Web 输出正式 /api/tree/events snapshot + delta stream", - "/api/mnote-web/stream 降级为 compat alias", - "Sidebar / page tree / file tree consumer 使用 Rust-owned stream" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_realtime && cargo test -p mnote-web stream && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/mnote-web/stream/route.test.ts src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/use-sidebar-tree-stream.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js", - "timeout_seconds": 4200 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "Rust Web /api/tree/events 已固定为 tree realtime stream 主链,输出 rust-web owner、snapshot/delta stream;Next /api/mnote-web/stream 保留 compat alias,consumer URL 指向 /api/tree/events。task112 针对 legacy Chromium renderer crash 仅做一次新 context 恢复。", - "timestamp": "2026-04-28T12:33:41Z" - } - ], - "completed_at": "2026-04-28T12:33:41Z" - }, - { - "id": "task-025", - "title": "Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands", - "status": "completed", - "priority": "P1", - "depends_on": [ - "task-024" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "rust/crates/mnote-web/src/routes/search.rs", - "rust/crates/mnote-web/src/routes/mindmap_shell.rs", - "rust/crates/mnote-web/src/routes/hermes.rs", - "wolai-frontend/src/components/search/SearchPaletteHost.tsx", - "wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx", - "wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx", - "scripts/task116-rust-web-islands-smoke.js" - ], - "acceptance": [ - "Rust Web 输出 Search server-first shell", - "Rust Web / Hermes 拥有 AI bridge session、tool event、client action contract", - "Rust Web 输出 Mindmap object shell,React runtime 只作为 island 或 legacy compat" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web search_shell && cargo test -p mnote-web ai_bridge && cargo test -p mnote-web mindmap_shell && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts && cd /mnt/Data1T/mnote && node scripts/task116-rust-web-islands-smoke.js", - "timeout_seconds": 4200 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "Search server-first shell、Hermes AI bridge owner contract、Mindmap object shell 已落到 Rust Web;React runtime 标记为 island/legacy compat;task116 smoke 证明三类 owner 已迁移。", - "timestamp": "2026-04-28T12:44:04Z" - } - ], - "completed_at": "2026-04-28T12:44:04Z" - }, - { - "id": "task-026", - "title": "Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-025" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "9f6a2c86", - "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "scope": [ - "ARCHITECTURE.md", - "design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md", - "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", - "scripts/task117-next-retirement-guard.js" - ], - "acceptance": [ - "task117 证明 SKIP_NEXT_LEGACY=1 时核心 smoke 仍可通过", - "默认启动链不再把 pnpm dev 作为 3000 owner,3104 不恢复为公开入口", - "ARCHITECTURE.md 与 3-1 同步 Next App Router legacy compat 口径,本 plan/checklist 移入 done" - ], - "validation": { - "command": "cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js && node scripts/task114-rust-web-gateway-entry-smoke.js && node scripts/task115-rust-web-document-shell-smoke.js && node scripts/task116-rust-web-islands-smoke.js && node scripts/task117-next-retirement-guard.js && git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web", - "timeout_seconds": 4800 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 1, - "description": "task117 已证明 SKIP_NEXT_LEGACY=1 时核心 shell 不依赖 Next legacy;desktop-hot 支持跳过 Next legacy;ARCHITECTURE/3-1 已同步 Next App Router legacy compat 口径,3-5/3-6 已移入 done。", - "timestamp": "2026-04-28T12:50:07Z" - } - ], - "completed_at": "2026-04-28T12:50:07Z" - } - ], - "session_count": 10, - "last_session": "2026-04-28T12:50:07Z", - "final_renderer_gate": { - "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", - "status": "completed", - "note": "task-001 至 task-009 只代表 Rust/WASM reducer runtime 阶段完成;不得再把 inline iframe srcdoc host 视为 final Rust DOM renderer。" - }, - "final_runtime_remaining": { - "source_doc": "design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md", - "status": "completed", - "tasks": [ - "task-014", - "task-015", - "task-016", - "task-017", - "task-018" - ] - }, - "main_web_execution_plane": { - "source_doc": "design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md", - "plan_doc": "design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", - "status": "planned", - "tasks": [ - "task-019", - "task-020", - "task-021", - "task-022", - "task-023", - "task-024", - "task-025", - "task-026" - ], - "note": "树域 Rust family 默认执行面已完成;主 Web 执行面仍需通过 task-019 至 task-026 迁到 mnote-web / Rust family。" - }, - "rust_web_3_4_undo": { - "source_doc": "design/03-rust-web/process/3-4-undo.md", - "status": "in_progress", - "dependency_chain": "A -> B/C -> D/E/F -> G -> H", - "tasks": [ - { - "id": "task-3-4-A", - "title": "Page Aggregate 升级为 core-protocol projection contract", - "status": "in_progress", - "depends_on": [], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol page_aggregate && cargo test -p bridge-runtime page_aggregate && cargo test -p mnote-web page_aggregate" - }, - { - "id": "task-3-4-B", - "title": "page.* 成为页面默认写命令族", - "status": "in_progress", - "depends_on": ["task-3-4-A"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime page_body_save && cargo test -p bridge-runtime page_head_update_title && cargo test -p bridge-runtime page_layout_update_options && cargo test -p mnote-web documents_save" - }, - { - "id": "task-3-4-C", - "title": "3000 Rust shell 消费 tree realtime event stream", - "status": "in_progress", - "depends_on": ["task-3-4-A"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_events && cargo test -p mnote-web tree_command && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js" - }, - { - "id": "task-3-4-D", - "title": "Search 收口为 server-first / kernel-aware 主链", - "status": "in_progress", - "depends_on": ["task-3-4-B", "task-3-4-C"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol search && cargo test -p bridge-runtime search_documents_query && cargo test -p mnote-web search && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task125-rust-web-search-server-first-smoke.js" - }, - { - "id": "task-3-4-E", - "title": "AI bridge 与结构化写链收口", - "status": "in_progress", - "depends_on": ["task-3-4-B", "task-3-4-C"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol ai && cargo test -p mnote-web hermes && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task126-rust-web-ai-bridge-structured-write-smoke.js" - }, - { - "id": "task-3-4-F", - "title": "Mindmap 成为 kernel projection / command truth", - "status": "in_progress", - "depends_on": ["task-3-4-B", "task-3-4-C"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap && cargo test -p bridge-runtime mindmap && cargo test -p mnote-web mindmap && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task127-rust-web-mindmap-kernel-projection-smoke.js" - }, - { - "id": "task-3-4-G", - "title": "Legacy Next / React 兼容层退役 gate", - "status": "in_progress", - "depends_on": ["task-3-4-D", "task-3-4-E", "task-3-4-F"], - "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cargo test -p mnote-web legacy_next && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task117-next-retirement-guard.js" - }, - { - "id": "task-3-4-H", - "title": "设计文档状态治理与执行节奏", - "status": "in_progress", - "depends_on": ["task-3-4-G"], - "validation": "cd /mnt/Data1T/mnote && rg -n \"\\\\[ \\\\]\" design/03-rust-web/process/3-4-undo.md && rg -n \"\\\\[done\\\\]|\\\\[recycle\\\\]\" design/03-rust-web design/old && git diff --check && git status --short --untracked-files=all" - } - ] - } -} diff --git a/package-lock.json b/package-lock.json index 1cd3a88c..4fa54479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,19 +7,287 @@ "": { "name": "mnote-desktop", "version": "0.1.0", + "dependencies": { + "@e965/xlsx": "0.20.3", + "docx-preview": "0.3.7", + "pdfjs-dist": "4.10.38", + "pptx-preview": "^1.0.7" + }, "devDependencies": { "@playwright/test": "^1.56.1", "playwright": "^1.56.1" } }, + "node_modules/@e965/xlsx": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@e965/xlsx/-/xlsx-0.20.3.tgz", + "integrity": "sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -28,6 +296,37 @@ "node": ">=18" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/docx-preview": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -43,14 +342,77 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdfjs-dist": { + "version": "4.10.38", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", + "integrity": "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.65" + } + }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -63,9 +425,9 @@ } }, "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -74,6 +436,102 @@ "engines": { "node": ">=18" } + }, + "node_modules/pptx-preview": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/pptx-preview/-/pptx-preview-1.0.7.tgz", + "integrity": "sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==", + "license": "ISC", + "dependencies": { + "echarts": "^5.5.1", + "jszip": "^3.10.1", + "lodash": "^4.17.21", + "tslib": "^2.7.0", + "uuid": "^10.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/package.json b/package.json index e29d1191..7fdeb332 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,17 @@ "version": "0.1.0", "private": true, "scripts": { - "desktop": "node scripts/desktop-prod.js", "desktop:hot": "node scripts/desktop-hot.js", - "dev:hot": "node scripts/dev-hot.js", - "check:local-first-convex-guard": "node scripts/check-local-first-convex-guard.js" + "dev:hot": "node scripts/dev-hot.js" }, "devDependencies": { "@playwright/test": "^1.56.1", "playwright": "^1.56.1" + }, + "dependencies": { + "@e965/xlsx": "0.20.3", + "docx-preview": "0.3.7", + "pdfjs-dist": "4.10.38", + "pptx-preview": "^1.0.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45374b2c..41d0d42e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + '@e965/xlsx': + specifier: 0.20.3 + version: 0.20.3 + docx-preview: + specifier: 0.3.7 + version: 0.3.7 + pdfjs-dist: + specifier: 4.10.38 + version: 4.10.38 devDependencies: '@playwright/test': specifier: ^1.56.1 @@ -17,16 +27,119 @@ importers: packages: + '@e965/xlsx@0.20.3': + resolution: {integrity: sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==} + engines: {node: '>=0.8'} + hasBin: true + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@playwright/test@1.57.0': resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} engines: {node: '>=18'} hasBin: true + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + docx-preview@0.3.7: + resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} engines: {node: '>=18'} @@ -37,15 +150,112 @@ packages: engines: {node: '>=18'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + snapshots: + '@e965/xlsx@0.20.3': {} + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@playwright/test@1.57.0': dependencies: playwright: 1.57.0 + core-util-is@1.0.3: {} + + docx-preview@0.3.7: + dependencies: + jszip: 3.10.1 + fsevents@2.3.2: optional: true + immediate@3.0.6: {} + + inherits@2.0.4: {} + + isarray@1.0.0: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + pako@1.0.11: {} + + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + playwright-core@1.57.0: {} playwright@1.57.0: @@ -53,3 +263,25 @@ snapshots: playwright-core: 1.57.0 optionalDependencies: fsevents: 2.3.2 + + process-nextick-args@2.0.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + safe-buffer@5.1.2: {} + + setimmediate@1.0.5: {} + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + util-deprecate@1.0.2: {} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8fddc4c3..5c22a1f9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -264,7 +264,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "storage-convex-bridge", "urlencoding", ] @@ -486,7 +485,6 @@ dependencies = [ "serde", "serde_json", "sha2", - "tempfile", "tracing", "uuid", ] @@ -532,7 +530,6 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "core-protocol" version = "0.1.0" dependencies = [ - "core-domain", "serde", "serde_json", ] @@ -1543,12 +1540,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1624,15 +1615,12 @@ dependencies = [ name = "mnote-cli" version = "0.1.0" dependencies = [ - "base64", "bridge-runtime", "clap", "core-protocol", "mnote-editor-core", - "reqwest", "serde", "serde_json", - "storage-convex-bridge", ] [[package]] @@ -1662,7 +1650,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "storage-convex-bridge", "time", "tokio", "tokio-stream", @@ -2279,19 +2266,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.38" @@ -2615,15 +2589,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "storage-convex-bridge" -version = "0.1.0" -dependencies = [ - "core-domain", - "core-protocol", - "event-log", -] - [[package]] name = "strsim" version = "0.11.1" @@ -2711,19 +2676,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4cce6425..fad81fa0 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/mnote-editor-core", "crates/mnote-cli", "crates/mnote-web", - "crates/storage-convex-bridge", "crates/index-fts", "crates/tree-shell-runtime-wasm", ] diff --git a/rust/crates/bridge-runtime/Cargo.toml b/rust/crates/bridge-runtime/Cargo.toml index 47703b15..f4da424c 100644 --- a/rust/crates/bridge-runtime/Cargo.toml +++ b/rust/crates/bridge-runtime/Cargo.toml @@ -11,7 +11,6 @@ core-domain = { path = "../core-domain" } core-protocol = { path = "../core-protocol" } event-log = { path = "../event-log" } index-fts = { path = "../index-fts" } -storage-convex-bridge = { path = "../storage-convex-bridge" } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 8c211882..5f7fac2d 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -44,13 +44,168 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, VecDeque}; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; -use storage_convex_bridge::{ - build_query_request, build_write_request, BridgeContext, BridgeError, BridgeErrorKind, -}; static TOOL_BLOCK_COUNTER: AtomicU64 = AtomicU64::new(1); static MINDMAP_UID_COUNTER: AtomicU64 = AtomicU64::new(1); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeContext { + pub deployment_id: Option, + pub project_id: Option, + pub request_id: String, + pub trace_id: String, + pub actor_type: String, + pub actor_id: String, + pub session_id: Option, + pub workspace_id: Option, + pub tenant_id: Option, + pub auth_token: Option, + pub source_channel: String, + pub source_client: String, + pub idempotency_key: Option, + 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 = Result; + +impl BridgeError { + pub fn validation(message: impl Into) -> Self { + Self { + kind: BridgeErrorKind::Validation, + message: message.into(), + } + } + + pub fn transport(message: impl Into) -> Self { + Self { + kind: BridgeErrorKind::Transport, + message: message.into(), + } + } + + pub fn not_found(message: impl Into) -> Self { + Self { + kind: BridgeErrorKind::NotFound, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetiredMutationRequest { + pub function_name: String, + pub deployment_id: Option, + pub project_id: Option, + pub workspace_id: Option, + pub request_id: String, + pub trace_id: String, + pub idempotency_key: Option, + pub actor_id: String, + pub payload_json: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetiredQueryRequest { + pub function_name: String, + pub deployment_id: Option, + pub project_id: Option, + pub workspace_id: Option, + 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( + context: &BridgeContext, + query: &QueryEnvelope, +) -> BridgeResult { + 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( + context: &BridgeContext, + command: &CommandEnvelope, +) -> BridgeResult { + let function_name = legacy_command_function_name(&command.name)?; + Ok(RetiredMutationRequest { + function_name: function_name.to_string(), + deployment_id: context.deployment_id.clone(), + project_id: context.project_id.clone(), + workspace_id: context.workspace_id.clone(), + request_id: context.request_id.clone(), + trace_id: context.trace_id.clone(), + idempotency_key: context.idempotency_key.clone(), + actor_id: context.actor_id.clone(), + payload_json: json!({ + "name": command.name, + "commandId": command.command_id, + }) + .to_string(), + }) +} + +fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> { + match name { + "documents.content.get" => Ok("documents:getContent"), + "documents.meta.get" => Ok("documents:getMeta"), + "mindmaps.get" => Ok("mindmaps:get"), + "search.documents" => Ok("documents:listSearchDataByWorkspace"), + "search_blocks" => Ok("documents:searchBlocks"), + "sidebar.dataset.list" => Ok("sidebar:datasetList"), + _ => Err(retired_bridge_error()), + } +} + +fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> { + match name { + "documents.title.update" => Ok("documents:updateTitle"), + "documents.save" => Ok("documents:updateContent"), + "documents.create" => Ok("documents:createWithParentReference"), + "documents.move" => Ok("documents:move"), + "documents.delete" => Ok("documents:softDelete"), + "documents.restore" => Ok("documents:restore"), + "insert_block" | "blocks.patch" | "blocks.move" | "blocks.embed" => { + Ok("documents:updateContent") + } + "mindmaps.put" => Ok("mindmaps:put"), + _ => Err(retired_bridge_error()), + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum RuntimeInput { @@ -1573,7 +1728,7 @@ fn normalize_resource_asset_type(raw: Option<&str>) -> String { "video" => "video".into(), "audio" => "audio".into(), "mindmap" => "mindmap".into(), - "luckysheet" => "luckysheet".into(), + "table" => "table".into(), _ => "file".into(), } } @@ -1633,7 +1788,7 @@ fn normalize_resource_lifecycle_kind(payload: &ResourceLifecycleCommandPayload) .map(str::trim) .unwrap_or("file") { - "table" | "online-table" | "luckysheet" => "table".into(), + "table" | "online-table" => "table".into(), "mindmap" | "map" => "mindmap".into(), _ => "file".into(), } @@ -7991,7 +8146,7 @@ fn classify_asset_kind( if lowered_type == "mindmap" { return KernelProjectionAssetKind::Mindmap; } - if lowered_type == "luckysheet" { + if lowered_type == "table" { return KernelProjectionAssetKind::Table; } if is_pdf_asset(file_name, mime_type) { @@ -19981,10 +20136,10 @@ mod tests { "id": "table_1", "workspace_id": "ws_1", "document_id": "page_root", - "asset_type": "luckysheet", - "file_name": "budget.luckysheet", + "asset_type": "table", + "file_name": "budget.table", "mime_type": "application/json", - "storage_path": "budget.luckysheet" + "storage_path": "budget.table" } ], "mindmap_asset_children": { diff --git a/rust/crates/bridge-runtime/src/main.rs b/rust/crates/bridge-runtime/src/main.rs index 26730dd3..f392d5ab 100644 --- a/rust/crates/bridge-runtime/src/main.rs +++ b/rust/crates/bridge-runtime/src/main.rs @@ -3,9 +3,8 @@ use std::io::{self, Read}; use bridge_runtime::{ build_artifact_success_response, build_failure_response, build_success_response, execute_runtime_command_artifact, execute_runtime_input, execute_runtime_query, - runtime_input_requests_result, RuntimeFailure, RuntimeInput, + runtime_input_requests_result, BridgeError, BridgeErrorKind, RuntimeFailure, RuntimeInput, }; -use storage_convex_bridge::{BridgeError, BridgeErrorKind}; fn main() { let input = match read_stdin() { diff --git a/rust/crates/control-plane/Cargo.toml b/rust/crates/control-plane/Cargo.toml index b741eafe..53e684a9 100644 --- a/rust/crates/control-plane/Cargo.toml +++ b/rust/crates/control-plane/Cargo.toml @@ -14,6 +14,3 @@ chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" sha2 = "0.10" hex = "0.4" - -[dev-dependencies] -tempfile = "3" diff --git a/rust/crates/control-plane/migrations/006-navigation-recent.sql b/rust/crates/control-plane/migrations/006-navigation-recent.sql new file mode 100644 index 00000000..7fb2e294 --- /dev/null +++ b/rust/crates/control-plane/migrations/006-navigation-recent.sql @@ -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); diff --git a/rust/crates/control-plane/src/migrations.rs b/rust/crates/control-plane/src/migrations.rs index 8fa347c2..5ffdcbed 100644 --- a/rust/crates/control-plane/src/migrations.rs +++ b/rust/crates/control-plane/src/migrations.rs @@ -26,6 +26,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ "v5-sidebar-shortcut-root-uri", include_str!("../migrations/005-sidebar-shortcut-root-uri.sql"), ), + ( + "v6-navigation-recent", + include_str!("../migrations/006-navigation-recent.sql"), + ), ]; /// Create the `_migrations` meta-table if it does not exist. @@ -108,6 +112,7 @@ mod tests { "ai_runtime_events", "sidebar_shortcuts", "user_ui_preferences", + "user_navigation_recent", ] { assert!(table_exists(&conn, table), "table {table} should exist"); } diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index 04978d8f..ac705520 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -243,6 +243,40 @@ pub struct UpsertUserUiPreferenceInput { pub value_json: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NavigationRecentRecord { + pub id: EntityId, + pub user_id: EntityId, + pub workspace_id: Option, + pub kind: String, + pub source_kind: String, + pub root_uri: String, + pub target_key: String, + pub relative_path: Option, + pub document_id: Option, + 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, + pub user_id: EntityId, + pub workspace_id: Option, + pub kind: String, + pub source_kind: String, + pub root_uri: String, + pub relative_path: Option, + pub document_id: Option, + pub title: String, + pub metadata_json: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AiPolicyRecord { pub id: EntityId, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 18a316bb..49c99226 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -11,10 +11,11 @@ use crate::model::{ AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, - DirectoryGrantRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, - ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, - UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, + DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, + ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, + UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, + UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, + UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; use crate::store::ControlPlaneStore; @@ -230,6 +231,56 @@ fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + 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 { + 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 { Ok(AiPolicyRecord { id: row.get(0)?, @@ -313,6 +364,33 @@ fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String { title.chars().take(80).collect() } +fn prune_navigation_recent_for_kind( + conn: &Connection, + user_id: &str, + kind: &str, +) -> Result<(), ControlPlaneError> { + let keep = match kind { + "folder" => 10_i64, + "page" => 20_i64, + _ => 20_i64, + }; + conn.execute( + "UPDATE user_navigation_recent + SET status = 'removed', updated_at = ?1, revision = revision + 1 + WHERE user_id = ?2 + AND kind = ?3 + AND status = 'active' + AND id NOT IN ( + SELECT id FROM user_navigation_recent + WHERE user_id = ?2 AND kind = ?3 AND status = 'active' + ORDER BY visited_at DESC, updated_at DESC + LIMIT ?4 + )", + params![now_text(), user_id, kind, keep], + )?; + Ok(()) +} + impl ControlPlaneStore for SqliteControlPlaneStore { fn upsert_user(&self, input: UpsertUserInput) -> Result { if input.username.trim().is_empty() { @@ -864,7 +942,8 @@ impl ControlPlaneStore for SqliteControlPlaneStore { AND status = 'active' AND ( ?2 = root_uri - OR (recursive = 1 AND ?2 LIKE root_uri || '%') + OR (recursive = 1 AND ?2 LIKE root_uri || '/%') + OR (recursive = 1 AND root_uri LIKE '%/' AND ?2 LIKE root_uri || '%') OR (?3 != '' AND ?3 = root_path) OR (?3 != '' AND recursive = 1 AND ?3 LIKE root_path || '/%') )", @@ -1382,6 +1461,138 @@ impl ControlPlaneStore for SqliteControlPlaneStore { Ok(rows) } + fn upsert_navigation_recent( + &self, + input: UpsertNavigationRecentInput, + ) -> Result { + 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::(&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, 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::, _>>()?; + 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::, _>>()?; + rows + }; + Ok(rows) + } + fn upsert_ai_policy( &self, input: UpsertAiPolicyInput, @@ -1985,7 +2196,8 @@ mod tests { use crate::model::{ password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, - UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserUiPreferenceInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, + UpsertUserUiPreferenceInput, }; fn store() -> SqliteControlPlaneStore { @@ -2158,6 +2370,36 @@ mod tests { assert_eq!(access.grant_ids.len(), 1); } + #[test] + fn resolve_access_does_not_match_sibling_uri_prefix() { + let store = store(); + create_user(&store, "reader"); + store + .grant_directory_access(DirectoryGrantInput { + user_id: "reader".to_string(), + workspace_id: None, + root_uri: "file:///tmp/mnote-ai-root".to_string(), + root_path: "/tmp/mnote-ai-root".to_string(), + permission: "write".to_string(), + recursive: true, + capabilities: vec!["ai".to_string()], + source: "admin".to_string(), + created_by: None, + }) + .expect("grant root"); + + let inside = store + .resolve_access("reader", "file:///tmp/mnote-ai-root/page.md") + .expect("resolve inside"); + assert_eq!(inside.permission, "write"); + + let sibling = store + .resolve_access("reader", "file:///tmp/mnote-ai-root-sibling/page.md") + .expect("resolve sibling"); + assert_eq!(sibling.permission, "none"); + assert!(sibling.grant_ids.is_empty()); + } + #[test] fn share_link_token_is_hashed_and_revocable() { let store = store(); @@ -2474,6 +2716,99 @@ mod tests { assert!(bob_sidebar_preferences.is_empty()); } + #[test] + fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() { + let store = store(); + create_user(&store, "alice"); + create_user(&store, "bob"); + + let first = store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "design".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert folder recent"); + let updated = store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design".to_string()), + document_id: None, + title: "Design".to_string(), + metadata_json: "{\"from\":\"test\"}".to_string(), + }) + .expect("upsert folder recent"); + assert_eq!(updated.id, first.id); + assert_eq!(updated.title, "Design"); + assert_eq!(updated.revision, first.revision + 1); + + store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "page".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some("design/Plan.md".to_string()), + document_id: Some("local-md:design~2FPlan.md".to_string()), + title: "Plan".to_string(), + metadata_json: "{}".to_string(), + }) + .expect("insert page recent"); + for index in 0..12 { + store + .upsert_navigation_recent(UpsertNavigationRecentInput { + id: None, + user_id: "alice".to_string(), + workspace_id: Some("local:_tmp_mnote".to_string()), + kind: "folder".to_string(), + source_kind: "local_folder".to_string(), + root_uri: "file:///tmp/mnote".to_string(), + relative_path: Some(format!("folder-{index}")), + document_id: None, + title: format!("folder-{index}"), + metadata_json: "{}".to_string(), + }) + .expect("insert bounded folder recent"); + } + + let alice_folders = store + .list_navigation_recent("alice", Some("folder"), 50) + .expect("list alice folders"); + assert_eq!(alice_folders.len(), 10); + assert_eq!(alice_folders[0].kind, "folder"); + assert_eq!(alice_folders[0].title, "folder-11"); + assert!(alice_folders.iter().all(|record| record.user_id == "alice")); + assert!(!alice_folders.iter().any(|record| record.title == "Design")); + + let alice_pages = store + .list_navigation_recent("alice", Some("page"), 20) + .expect("list alice pages"); + assert_eq!(alice_pages.len(), 1); + assert_eq!( + alice_pages[0].document_id.as_deref(), + Some("local-md:design~2FPlan.md") + ); + + let bob_recent = store + .list_navigation_recent("bob", None, 20) + .expect("list bob recent"); + assert!(bob_recent.is_empty()); + } + #[test] fn ai_policy_upsert_returns_workspace_policy_before_user_policy() { let store = store(); diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs index 36f9f849..9ddd8775 100644 --- a/rust/crates/control-plane/src/store.rs +++ b/rust/crates/control-plane/src/store.rs @@ -5,11 +5,11 @@ use crate::model::{ AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, - DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, - SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord, - UserUiPreferenceRecord, WorkspaceRecord, + DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, + OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, + SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; pub trait ControlPlaneStore: Send + Sync { @@ -136,6 +136,18 @@ pub trait ControlPlaneStore: Send + Sync { source_kind: Option<&str>, ) -> Result, ControlPlaneError>; + fn upsert_navigation_recent( + &self, + input: UpsertNavigationRecentInput, + ) -> Result; + + fn list_navigation_recent( + &self, + user_id: &str, + kind: Option<&str>, + limit: usize, + ) -> Result, ControlPlaneError>; + fn upsert_ai_policy( &self, input: UpsertAiPolicyInput, diff --git a/rust/crates/core-protocol/Cargo.toml b/rust/crates/core-protocol/Cargo.toml index 1b0d7823..e8420a68 100644 --- a/rust/crates/core-protocol/Cargo.toml +++ b/rust/crates/core-protocol/Cargo.toml @@ -6,6 +6,5 @@ license.workspace = true authors.workspace = true [dependencies] -core-domain = { path = "../core-domain" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rust/crates/core-protocol/src/command.rs b/rust/crates/core-protocol/src/command.rs index 935a9fe2..f2bf4698 100644 --- a/rust/crates/core-protocol/src/command.rs +++ b/rust/crates/core-protocol/src/command.rs @@ -202,7 +202,12 @@ pub struct DeleteBlock { /// - `tree.focusKind` — 当前聚焦的树类型 ("file_tree" | "page_tree" | "none") /// - `tree.selectionCount` — 当前选中行数 (i64) /// - `tree.selectionResourceKind` — 选中资源的类型 ("page" | "folder" | "mindmap" | "asset" | ...) +/// - `tree.targetResourceKind` — 当前命令目标资源类型 +/// - `tree.targetRowKind` — 当前命令目标行类型 +/// - `tree.targetIsFolder` — 当前命令目标是否文件夹 +/// - `tree.targetIsAsset` — 当前命令目标是否资源文件 /// - `editor.dirty` — bool,当前编辑器是否有未保存修改 +/// - `editor.dirtyState` — 当前编辑器 dirty 状态 ("clean" | "dirty" | "stale" | ...) /// - `editor.hasSelection` — bool,编辑器是否有文本选区 /// - `ai.canWrite` — bool,当前会话是否允许 AI 写入 #[derive(Debug, Clone, PartialEq)] @@ -217,8 +222,18 @@ pub struct CommandContext { pub tree_selection_count: i64, /// 选择资源的 kind pub tree_selection_resource_kind: Option, + /// 当前命令目标资源 kind + pub tree_target_resource_kind: Option, + /// 当前命令目标行 kind + pub tree_target_row_kind: Option, + /// 当前命令目标是否文件夹 + pub tree_target_is_folder: bool, + /// 当前命令目标是否资源文件 + pub tree_target_is_asset: bool, /// 编辑器是否 dirty pub editor_dirty: bool, + /// 编辑器 dirty 状态 + pub editor_dirty_state: Option, /// 编辑器是否有选区 pub editor_has_selection: bool, /// AI 是否可写 @@ -233,7 +248,12 @@ impl CommandContext { tree_focus_kind: None, tree_selection_count: 0, tree_selection_resource_kind: None, + tree_target_resource_kind: None, + tree_target_row_kind: None, + tree_target_is_folder: false, + tree_target_is_asset: false, editor_dirty: false, + editor_dirty_state: Some("clean".to_string()), editor_has_selection: false, ai_can_write: true, } @@ -256,7 +276,21 @@ impl CommandContext { .tree_selection_resource_kind .as_deref() .map(|s| CommandContextValue::String(s.to_string())), + "tree.targetResourceKind" => self + .tree_target_resource_kind + .as_deref() + .map(|s| CommandContextValue::String(s.to_string())), + "tree.targetRowKind" => self + .tree_target_row_kind + .as_deref() + .map(|s| CommandContextValue::String(s.to_string())), + "tree.targetIsFolder" => Some(CommandContextValue::Bool(self.tree_target_is_folder)), + "tree.targetIsAsset" => Some(CommandContextValue::Bool(self.tree_target_is_asset)), "editor.dirty" => Some(CommandContextValue::Bool(self.editor_dirty)), + "editor.dirtyState" => self + .editor_dirty_state + .as_deref() + .map(|s| CommandContextValue::String(s.to_string())), "editor.hasSelection" => Some(CommandContextValue::Bool(self.editor_has_selection)), "ai.canWrite" => Some(CommandContextValue::Bool(self.ai_can_write)), _ => None, @@ -293,9 +327,44 @@ impl CommandContext { self } + /// 用 builder 模式设置命令目标 resource kind。 + pub fn with_tree_target_resource_kind(mut self, kind: &str) -> Self { + self.tree_target_resource_kind = Some(kind.to_string()); + self + } + + /// 用 builder 模式设置命令目标 row kind。 + pub fn with_tree_target_row_kind(mut self, kind: &str) -> Self { + self.tree_target_row_kind = Some(kind.to_string()); + self + } + + /// 用 builder 模式设置命令目标是否文件夹。 + pub fn with_tree_target_is_folder(mut self, is_folder: bool) -> Self { + self.tree_target_is_folder = is_folder; + self + } + + /// 用 builder 模式设置命令目标是否资源文件。 + pub fn with_tree_target_is_asset(mut self, is_asset: bool) -> Self { + self.tree_target_is_asset = is_asset; + self + } + /// 用 builder 模式设置 editor dirty。 pub fn with_editor_dirty(mut self, dirty: bool) -> Self { self.editor_dirty = dirty; + if dirty && self.editor_dirty_state.as_deref() == Some("clean") { + self.editor_dirty_state = Some("dirty".to_string()); + } + self + } + + /// 用 builder 模式设置 editor dirty 状态。 + pub fn with_editor_dirty_state(mut self, state: &str) -> Self { + let normalized = state.to_lowercase(); + self.editor_dirty_state = Some(normalized.clone()); + self.editor_dirty = normalized != "clean"; self } @@ -800,6 +869,25 @@ mod command_context_tests { assert!(is_command_enabled(&ctx, Some(""))); } + #[test] + fn command_context_exposes_target_and_dirty_state_keys() { + let ctx = CommandContext::new() + .with_tree_target_resource_kind("folder") + .with_tree_target_row_kind("directory") + .with_tree_target_is_folder(true) + .with_tree_target_is_asset(false) + .with_editor_dirty_state("Dirty") + .with_ai_can_write(false); + + assert!(check_when(&ctx, "tree.targetResourceKind == folder").unwrap()); + assert!(check_when(&ctx, "tree.targetRowKind == directory").unwrap()); + assert!(check_when(&ctx, "tree.targetIsFolder").unwrap()); + assert!(!check_when(&ctx, "tree.targetIsAsset").unwrap()); + assert!(check_when(&ctx, "editor.dirty").unwrap()); + assert!(check_when(&ctx, "editor.dirtyState == dirty").unwrap()); + assert!(!check_when(&ctx, "ai.canWrite").unwrap()); + } + #[test] fn command_context_number_comparison() { let ctx = CommandContext::new().with_tree_selection_count(3); diff --git a/rust/crates/core-protocol/src/kernel.rs b/rust/crates/core-protocol/src/kernel.rs index 6537e976..fc3e68f6 100644 --- a/rust/crates/core-protocol/src/kernel.rs +++ b/rust/crates/core-protocol/src/kernel.rs @@ -94,6 +94,10 @@ pub struct PageBodyWriteRequest { alias = "conflict_detection_key" )] pub expected_file_version: Option, + #[serde(default, alias = "write_intent_id")] + pub write_intent_id: Option, + #[serde(default, alias = "save_operation_id")] + pub save_operation_id: Option, #[serde(default)] pub base_content_hash: Option, #[serde(default = "default_page_body_content_format")] @@ -226,6 +230,12 @@ pub struct DocumentBuffer { pub last_saved_at: Option, /// 外部修改者 pub external_actor: Option, + /// 最近一次由 MNote 发起写入的意图 ID。 + #[serde(default)] + pub last_write_intent_id: Option, + /// 最近一次保存请求实例 ID。 + #[serde(default)] + pub last_save_operation_id: Option, } impl DocumentBuffer { @@ -240,10 +250,26 @@ impl DocumentBuffer { } pub fn mark_saved(&mut self, file_version: String, content_hash: String) { + self.mark_saved_with_operation(file_version, content_hash, None, None); + } + + pub fn mark_saved_with_operation( + &mut self, + file_version: String, + content_hash: String, + write_intent_id: Option, + save_operation_id: Option, + ) { self.file_version = Some(file_version); self.base_content_hash = Some(content_hash); self.current_content_hash = None; self.dirty_state = DocBufferDirtyState::Clean; + if write_intent_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) { + self.last_write_intent_id = write_intent_id; + } + if save_operation_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) { + self.last_save_operation_id = save_operation_id; + } self.last_saved_at = Some( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -881,6 +907,8 @@ mod tests { "sourceKind": "local_folder", "rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space", "expectedFileVersion": "local-md:local-md:README.md:1:2:hash", + "writeIntentId": "intent:editor:abc", + "saveOperationId": "save:op:abc", "baseContentHash": "sha256:base", "contentFormat": "editorBlocks", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}], @@ -894,6 +922,8 @@ mod tests { request.expected_file_version.as_deref(), Some("local-md:local-md:README.md:1:2:hash") ); + assert_eq!(request.write_intent_id.as_deref(), Some("intent:editor:abc")); + assert_eq!(request.save_operation_id.as_deref(), Some("save:op:abc")); assert_eq!(request.content_format, "editorBlocks"); assert_eq!(request.editor_source.as_deref(), Some("tiptap")); @@ -902,6 +932,8 @@ mod tests { value["expectedFileVersion"], json!("local-md:local-md:README.md:1:2:hash") ); + assert_eq!(value["writeIntentId"], json!("intent:editor:abc")); + assert_eq!(value["saveOperationId"], json!("save:op:abc")); assert_eq!(value["baseContentHash"], json!("sha256:base")); assert_eq!(value["contentFormat"], json!("editorBlocks")); assert_eq!(value["editorSource"], json!("tiptap")); @@ -1009,11 +1041,15 @@ mod tests { last_loaded_at: Some(1_700_000_000_000i64), last_saved_at: Some(1_700_000_000_001i64), external_actor: None, + last_write_intent_id: Some("intent:editor:abc".into()), + last_save_operation_id: Some("save:op:abc".into()), }; let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化"); assert_eq!(value["dirtyState"], json!("clean")); assert_eq!(value["fileVersion"], json!("v1")); + assert_eq!(value["lastWriteIntentId"], json!("intent:editor:abc")); + assert_eq!(value["lastSaveOperationId"], json!("save:op:abc")); assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md")); let decoded: DocumentBuffer = @@ -1046,6 +1082,8 @@ mod tests { last_loaded_at: None, last_saved_at: None, external_actor: None, + last_write_intent_id: None, + last_save_operation_id: None, }; assert!(!buf.is_dirty()); diff --git a/rust/crates/mnote-cli/Cargo.toml b/rust/crates/mnote-cli/Cargo.toml index 97b7aafd..ca14b845 100644 --- a/rust/crates/mnote-cli/Cargo.toml +++ b/rust/crates/mnote-cli/Cargo.toml @@ -6,12 +6,9 @@ license.workspace = true authors.workspace = true [dependencies] -base64 = "0.22" bridge-runtime = { path = "../bridge-runtime" } clap = { version = "4.5.38", features = ["derive"] } core-protocol = { path = "../core-protocol" } mnote-editor-core = { path = "../mnote-editor-core" } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -storage-convex-bridge = { path = "../storage-convex-bridge" } diff --git a/rust/crates/mnote-cli/README.md b/rust/crates/mnote-cli/README.md index 5c442b07..3d003f32 100644 --- a/rust/crates/mnote-cli/README.md +++ b/rust/crates/mnote-cli/README.md @@ -2,8 +2,8 @@ 当前 crate 是 Phase 2 的最小 CLI 协议入口。 -注意:当前 `page` / `block` / `tool run doc_*` 执行面仍是 Convex -`documents.*` 兼容路径,只适合调试、迁移验证和 cloud workspace 兼容场景。 +注意:旧 `page` / `block` / `tool run doc_*` 执行面曾连接 Convex +`documents.*` 兼容路径;该执行链已退役,只保留 JSON 计划输出和历史迁移对照。 local-first 普通 Markdown 正文主链应优先走本地文件引用、Page Aggregate、 Rust Web local_folder source 和 agent 原生 patch/diff,不应把本 CLI 视为正文真源入口。 diff --git a/rust/crates/mnote-cli/src/lib.rs b/rust/crates/mnote-cli/src/lib.rs index caaec94e..28bf1fe0 100644 --- a/rust/crates/mnote-cli/src/lib.rs +++ b/rust/crates/mnote-cli/src/lib.rs @@ -1,7 +1,7 @@ -use base64::Engine; use bridge_runtime::{ - execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, - RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire, + build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError, + RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire, + RuntimeToolInvocationWire, }; use clap::ValueEnum; use core_protocol::{ @@ -16,11 +16,8 @@ use mnote_editor_core::{ EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession, VisibilitySnapshot, }; -use reqwest::blocking::Client; use serde::Serialize; use serde_json::{json, Value}; -use std::env; -use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliContext { @@ -1554,7 +1551,7 @@ fn execute_query( "documents.content.get" | "documents.meta.get" | "mindmaps.get" - | "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx), + | "sidebar.dataset.list" => execute_retired_cloud_transport(name), "search.documents" => execute_search_documents(transport, context, cli_ctx), other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))), } @@ -1574,7 +1571,7 @@ fn execute_command( | "documents.restore" | "documents.title.update" | "documents.save" - | "mindmaps.put" => execute_convex_mutation(transport, cli_ctx), + | "mindmaps.put" => execute_retired_cloud_transport(name), "insert_block" => execute_block_insert(normalized_input, context, cli_ctx), "blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx), "blocks.move" => execute_block_move(normalized_input, cli_ctx), @@ -1649,313 +1646,35 @@ fn merge_tool_args( } fn execute_search_documents( - transport: &CliTransportPlan, - context: &CliOutputContext, - cli_ctx: &CliContext, + _transport: &CliTransportPlan, + _context: &CliOutputContext, + _cli_ctx: &CliContext, ) -> CliResult { - let workspace_id = transport - .args_json - .get("workspaceId") - .and_then(Value::as_str) - .ok_or_else(|| CliError::validation("search documents 缺少 workspaceId"))?; - let documents = execute_convex_query_raw( - "documents:listSearchDataByWorkspace", - json!({ "workspaceId": workspace_id }), - cli_ctx, - )?; - let mindmaps = execute_convex_query_raw( - "mindmaps:listByWorkspace", - json!({ "workspaceId": workspace_id, "includeDeleted": false }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let tables = execute_convex_query_raw( - "tables:listByWorkspaceForSearch", - json!({ - "userId": context.actor_id, - "workspaceId": workspace_id, - "includeArchived": false, - "limit": 3000, - }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let table_rows = execute_convex_query_raw( - "tables:listRowsByWorkspaceForSearch", - json!({ - "userId": context.actor_id, - "workspaceId": workspace_id, - "limit": 8000, - }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let assets = execute_convex_query_raw( - "mediaAssets:listSearchDataByWorkspace", - json!({ - "userId": context.actor_id, - "workspaceId": workspace_id, - "includeDeleted": false, - "limit": 5000, - }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let dataset = json!({ - "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ - "id": item.get("id").cloned().unwrap_or(Value::Null), - "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), - "title": item.get("title").cloned().unwrap_or(Value::Null), - "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), - "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), - "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null), - })).collect::>(), - "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::>(), - "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::>(), - "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::>(), - "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::>(), - }); - execute_runtime_query(RuntimeInput::Query { - context: build_runtime_context(context, cli_ctx), - query: RuntimeQueryEnvelopeWire { - name: "search.documents".into(), - payload: transport.args_json.clone(), - }, - data: Some(dataset), - }) - .map_err(|error| CliError::transport(error.message)) + execute_retired_cloud_transport("search.documents") } fn execute_block_insert( - normalized_input: &Value, - context: &CliOutputContext, - cli_ctx: &CliContext, + _normalized_input: &Value, + _context: &CliOutputContext, + _cli_ctx: &CliContext, ) -> CliResult { - let page_id = required_str(normalized_input, "pageId")?; - let workspace_id = required_str(normalized_input, "workspaceId")?; - let content = required_str(normalized_input, "content")?; - let block_type = normalized_input - .get("blockType") - .and_then(Value::as_str) - .unwrap_or("paragraph"); - let before_block_id = None::; - let after_block_id = normalized_input - .get("prevBlockId") - .and_then(Value::as_str) - .map(str::to_string); - let data = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; - let block_specs = json!([{ - "type": block_type, - "text": content, - }]); - let runtime_result = execute_runtime_query(RuntimeInput::Tool { - context: build_runtime_context(context, cli_ctx), - tool: RuntimeToolInvocationWire { - tool: "doc_insert_blocks".into(), - kind: "command".into(), - mode: Some("result".into()), - args_json: json!({ - "pageId": page_id, - "workspaceId": workspace_id, - "afterBlockId": after_block_id, - "beforeBlockId": before_block_id, - "blocks": block_specs, - }), - target: Some(RuntimeTargetWire { - workspace_id: Some(workspace_id.to_string()), - page_id: Some(page_id.to_string()), - block_id: None, - }), - reason: cli_ctx.reason.clone(), - refs: vec!["mnote-cli-execute".into()], - }, - data: Some(data.clone()), - }) - .map_err(|error| CliError::transport(error.message))?; - let next_content = write_blocks_back( - &data, - runtime_result - .get("data") - .cloned() - .unwrap_or(Value::Array(vec![])), - ); - let save_result = execute_convex_mutation_raw( - "documents:updateContent", - json!({ - "id": page_id, - "content": next_content, - "expectedRevision": data.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": data.get("conflict_detection_key").cloned().unwrap_or(Value::Null), - }), - cli_ctx, - )?; - Ok(json!({ - "ok": true, - "pageId": page_id, - "inserted": runtime_result.get("inserted").cloned().unwrap_or(Value::Array(vec![])), - "revision": save_result.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": save_result.get("conflict_detection_key").cloned().unwrap_or(Value::Null), - "data": runtime_result.get("data").cloned().unwrap_or(Value::Null), - })) + execute_retired_cloud_transport("insert_block") } fn execute_block_patch( - normalized_input: &Value, - transport: &CliTransportPlan, - cli_ctx: &CliContext, + _normalized_input: &Value, + _transport: &CliTransportPlan, + _cli_ctx: &CliContext, ) -> CliResult { - let page_id = required_str(normalized_input, "pageId")?; - let block_id = required_str(normalized_input, "blockId")?; - let snapshot = normalized_input - .get("snapshot") - .cloned() - .ok_or_else(|| CliError::validation("block patch 缺少 snapshot"))?; - let current = - execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; - let next_blocks = replace_block_subtree(extract_blocks(¤t), block_id, &snapshot)?; - let args = json!({ - "id": page_id, - "content": write_blocks_back(¤t, Value::Array(next_blocks)), - "expectedRevision": transport.args_json.get("expectedRevision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": transport.args_json.get("conflictDetectionKey").cloned().unwrap_or(Value::Null), - }); - execute_convex_mutation_raw("documents:updateContent", args, cli_ctx) + execute_retired_cloud_transport("blocks.patch") } -fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { - let source_document_id = required_str(normalized_input, "sourceDocumentId")?; - let target_document_id = required_str(normalized_input, "targetDocumentId")?; - let block_id = required_str(normalized_input, "blockId")?; - - let source = execute_convex_query_raw( - "documents:getContent", - json!({ "id": source_document_id }), - cli_ctx, - )?; - let target = execute_convex_query_raw( - "documents:getContent", - json!({ "id": target_document_id }), - cli_ctx, - )?; - let source_blocks = extract_blocks(&source); - let target_blocks = extract_blocks(&target); - let (removed, next_source_blocks) = remove_block_subtree(source_blocks, block_id)?; - let mut next_target_blocks = target_blocks; - next_target_blocks.push(removed); - let next_source_content = write_blocks_back(&source, Value::Array(next_source_blocks)); - let next_target_content = write_blocks_back(&target, Value::Array(next_target_blocks)); - let source_save = execute_convex_mutation_raw( - "documents:updateContent", - json!({ - "id": source_document_id, - "content": next_source_content, - "expectedRevision": source.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": source.get("conflict_detection_key").cloned().unwrap_or(Value::Null), - }), - cli_ctx, - )?; - let target_save = execute_convex_mutation_raw( - "documents:updateContent", - json!({ - "id": target_document_id, - "content": next_target_content, - "expectedRevision": target.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": target.get("conflict_detection_key").cloned().unwrap_or(Value::Null), - }), - cli_ctx, - )?; - Ok(json!({ - "ok": true, - "sourceRevision": source_save.get("revision").cloned().unwrap_or(Value::Null), - "targetRevision": target_save.get("revision").cloned().unwrap_or(Value::Null), - })) +fn execute_block_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult { + execute_retired_cloud_transport("blocks.move") } -fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { - let source_document_id = required_str(normalized_input, "sourceDocumentId")?; - let target_document_id = required_str(normalized_input, "targetDocumentId")?; - let block_id = required_str(normalized_input, "blockId")?; - let target_block_id = normalized_input - .get("targetBlockId") - .and_then(Value::as_str); - - let source_meta = execute_convex_query_raw( - "documents:getMeta", - json!({ "id": source_document_id }), - cli_ctx, - )?; - let target_content = execute_convex_query_raw( - "documents:getContent", - json!({ "id": target_document_id }), - cli_ctx, - )?; - let target_meta = execute_convex_query_raw( - "documents:getMeta", - json!({ "id": target_document_id }), - cli_ctx, - )?; - let mut target_blocks = extract_blocks(&target_content); - let reference_block = json!({ - "id": format!("cli_embed_{}_{}", target_document_id, block_id), - "type": "blockReference", - "props": { - "sourceDocumentId": source_document_id, - "targetBlockId": block_id, - "display": "embed", - }, - "content": [], - "children": [], - }); - let anchor_id = target_block_id.map(str::to_string).or_else(|| { - target_meta - .get("embed_default_block_id") - .and_then(Value::as_str) - .map(str::to_string) - }); - if let Some(anchor_id) = anchor_id { - if let Some(index) = find_block_index(&target_blocks, &anchor_id) { - target_blocks.insert(index + 1, reference_block.clone()); - } else { - target_blocks.push(reference_block.clone()); - } - } else { - target_blocks.push(reference_block.clone()); - } - let saved = execute_convex_mutation_raw( - "documents:updateContent", - json!({ - "id": target_document_id, - "content": write_blocks_back(&target_content, Value::Array(target_blocks)), - "expectedRevision": target_content.get("revision").cloned().unwrap_or(Value::Null), - "conflictDetectionKey": target_content.get("conflict_detection_key").cloned().unwrap_or(Value::Null), - }), - cli_ctx, - )?; - Ok(json!({ - "ok": true, - "sourceTitle": source_meta.get("title").cloned().unwrap_or(Value::Null), - "referenceBlockId": reference_block.get("id").cloned().unwrap_or(Value::Null), - "revision": saved.get("revision").cloned().unwrap_or(Value::Null), - })) +fn execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult { + execute_retired_cloud_transport("blocks.embed") } fn build_tool_target( @@ -2007,7 +1726,7 @@ fn load_tool_data( tool_name: &str, args: &Value, target: Option<&RuntimeTargetWire>, - cli_ctx: &CliContext, + _cli_ctx: &CliContext, ) -> CliResult { match tool_name { "search_web" | "slash_run" | "event_replay" | "index_rebuild" => { @@ -2015,112 +1734,24 @@ fn load_tool_data( } "image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })), "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => { - let page_id = read_doc_target(args, target)?; - let result = execute_convex_query_raw( - "documents:getContent", - json!({ "id": page_id }), - cli_ctx, - )?; - Ok( - json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }), - ) + let _ = read_doc_target(args, target)?; + Err(retired_cloud_transport_error(tool_name)) } "docs_read" => { let document_id = args .get("documentId") .and_then(Value::as_str) .ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?; - let meta = execute_convex_query_raw( - "documents:getMeta", - json!({ "id": document_id }), - cli_ctx, - )?; - let content = execute_convex_query_raw( - "documents:getContent", - json!({ "id": document_id }), - cli_ctx, - )?; - Ok(json!({ - "source": "convex", - "documentId": document_id, - "title": meta.get("title").cloned().unwrap_or(Value::Null), - "workspaceId": meta.get("workspace_id").cloned().unwrap_or(Value::Null), - "parentId": meta.get("parent_id").cloned().unwrap_or(Value::Null), - "updatedAt": meta.get("updated_at").cloned().unwrap_or(Value::Null), - "rawText": extract_raw_text(&content), - "rawTextLength": extract_raw_text(&content).chars().count(), - "content": content.get("content").cloned().unwrap_or(Value::Null), - })) + let _ = document_id; + Err(retired_cloud_transport_error(tool_name)) } "docs_search" => { let workspace_id = args .get("workspaceId") .and_then(Value::as_str) .ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?; - let documents = execute_convex_query_raw( - "documents:listSearchDataByWorkspace", - json!({ "workspaceId": workspace_id }), - cli_ctx, - )?; - let mindmaps = execute_convex_query_raw( - "mindmaps:listByWorkspace", - json!({ "workspaceId": workspace_id, "includeDeleted": false }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let tables = execute_convex_query_raw( - "tables:listByWorkspaceForSearch", - json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000 }), - cli_ctx, - ).unwrap_or_else(|_| Value::Array(vec![])); - let table_rows = execute_convex_query_raw( - "tables:listRowsByWorkspaceForSearch", - json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "limit": 8000 }), - cli_ctx, - ) - .unwrap_or_else(|_| Value::Array(vec![])); - let assets = execute_convex_query_raw( - "mediaAssets:listSearchDataByWorkspace", - json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000 }), - cli_ctx, - ).unwrap_or_else(|_| Value::Array(vec![])); - Ok(json!({ - "source": "convex", - "datasets": [{ - "workspaceId": workspace_id, - "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ - "id": item.get("id").cloned().unwrap_or(Value::Null), - "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), - "title": item.get("title").cloned().unwrap_or(Value::Null), - "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), - "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), - "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null) - })).collect::>(), - "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::>(), - "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::>(), - "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::>(), - "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::>() - }] - })) + let _ = workspace_id; + Err(retired_cloud_transport_error(tool_name)) } "mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => { let document_id = read_doc_target(args, target)?; @@ -2129,14 +1760,8 @@ fn load_tool_data( .and_then(Value::as_str) .or_else(|| target.and_then(|value| value.block_id.as_deref())) .ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?; - let result = execute_convex_query_raw( - "mindmaps:get", - json!({ "docId": document_id, "mindmapId": mindmap_id }), - cli_ctx, - )?; - Ok( - json!({ "source": "convex", "data": result.get("data").cloned().unwrap_or(Value::Null), "meta": result.get("meta").cloned().unwrap_or(Value::Null) }), - ) + let _ = (document_id, mindmap_id); + Err(retired_cloud_transport_error(tool_name)) } other => Err(CliError::validation(format!( "暂不支持 tool 数据加载: {other}" @@ -2144,183 +1769,14 @@ fn load_tool_data( } } -fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { - execute_convex_query_raw( - &transport.function_name, - transport.args_json.clone(), - cli_ctx, - ) +fn execute_retired_cloud_transport(operation: &str) -> CliResult { + Err(retired_cloud_transport_error(operation)) } -fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { - execute_convex_mutation_raw( - &transport.function_name, - transport.args_json.clone(), - cli_ctx, - ) -} - -fn execute_convex_query_raw( - function_name: &str, - args_json: Value, - cli_ctx: &CliContext, -) -> CliResult { - 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 { - 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 { - 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 { - 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 { - 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> { - if let Ok(value) = env::var(key) { - let trimmed = value.trim().to_string(); - if !trimmed.is_empty() { - return Ok(Some(trimmed)); - } - } - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../..") - .join(".env.all"); - let content = std::fs::read_to_string(&root) - .map_err(|error| CliError::transport(format!("读取 .env.all 失败: {error}")))?; - for line in content.lines() { - let line = line.trim_end_matches('\r'); - if line.starts_with('#') || line.trim().is_empty() { - continue; - } - if let Some((k, v)) = line.split_once('=') { - if k.trim() == key { - let trimmed = v.trim().trim_matches('"').to_string(); - if !trimmed.is_empty() { - return Ok(Some(trimmed)); - } - } - } - } - Ok(None) +fn retired_cloud_transport_error(operation: &str) -> CliError { + CliError::validation(format!( + "旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径" + )) } fn build_runtime_context( @@ -2341,6 +1797,10 @@ fn build_runtime_context( source: RuntimeSourceWire { channel: "cli".into(), client: "mnote-cli".into(), + source_kind: None, + root_uri: None, + workspace_id: None, + capabilities: Vec::new(), }, tenant_id: None, auth_token: None, @@ -2350,125 +1810,6 @@ fn build_runtime_context( } } -fn extract_blocks(content_result: &Value) -> Vec { - 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 { - 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, block_id: &str) -> CliResult<(Value, Vec)> { - 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, - block_id: &str, - next_block: &Value, -) -> CliResult> { - 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) { - 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 { args.get("documentId") .and_then(Value::as_str) @@ -2478,13 +1819,6 @@ fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResul .ok_or_else(|| CliError::validation("缺少 documentId/pageId")) } -fn required_str<'a>(value: &'a Value, key: &str) -> CliResult<&'a str> { - value - .get(key) - .and_then(Value::as_str) - .ok_or_else(|| CliError::validation(format!("缺少 {key}"))) -} - fn build_query_output( domain: &str, action: &str, @@ -2625,7 +1959,7 @@ fn invocation_kind_label(kind: &InvocationKind) -> &'static str { } } -fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError { +fn map_bridge_error(error: BridgeError) -> CliError { CliError::validation(error.message) } @@ -2842,32 +2176,6 @@ mod tests { } } - #[test] - fn convex_dev_identity_prefers_explicit_cli_actor() { - let ctx = CliContext { - actor_id: "nn7bhmt782sykdrecah0rbe2nx867ks1".into(), - ..CliContext::default() - }; - let user_id = normalize_actor_for_convex_identity(&ctx.actor_id) - .expect("显式 CLI actor 应成为 Convex 写入身份"); - let identity = build_convex_dev_identity(&user_id, "开发用户", "dev@mnote.local"); - - assert_eq!( - identity["subject"], - json!("nn7bhmt782sykdrecah0rbe2nx867ks1") - ); - assert_eq!( - identity["tokenIdentifier"], - json!("dev-user|nn7bhmt782sykdrecah0rbe2nx867ks1") - ); - } - - #[test] - fn convex_dev_identity_ignores_legacy_default_cli_actor() { - assert_eq!(normalize_actor_for_convex_identity("cli_user"), None); - assert_eq!(normalize_actor_for_convex_identity(" anonymous "), None); - } - #[test] fn page_move_json_contract_uses_documents_move() { let output = plan_page_move( @@ -3033,55 +2341,4 @@ mod tests { _ => panic!("expected command output"), } } - - #[test] - fn replace_block_subtree_preserves_other_blocks() { - let next_blocks = replace_block_subtree( - vec![ - json!({ - "id": "block_1", - "type": "paragraph", - "content": [{"type": "text", "text": "old"}], - "children": [], - }), - json!({ - "id": "block_2", - "type": "paragraph", - "content": [{"type": "text", "text": "keep"}], - "children": [], - }), - ], - "block_1", - &json!({ - "type": "paragraph", - "content": [{"type": "text", "text": "new"}], - "children": [], - }), - ) - .expect("block replacement should succeed"); - - assert_eq!( - next_blocks, - vec![ - json!({ - "id": "block_1", - "type": "paragraph", - "content": [{"type": "text", "text": "new"}], - "children": [], - }), - json!({ - "id": "block_2", - "type": "paragraph", - "content": [{"type": "text", "text": "keep"}], - "children": [], - }), - ] - ); - } - - #[test] - fn read_env_or_dotenv_trims_crlf_values() { - let got = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL").expect("read env should work"); - assert_eq!(got.as_deref(), Some("http://127.0.0.1:3210")); - } } diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 5b8dab8d..61e95e88 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -19,7 +19,6 @@ mnote-editor-core = { path = "../mnote-editor-core" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -storage-convex-bridge = { path = "../storage-convex-bridge" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = "0.29" diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index bf5e0e6f..dc8fdd65 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -141,7 +141,7 @@ import { }; const pageAggregateUrlFromDescriptor = (descriptor) => { const url = new URL(`/api/page-aggregate/${encodeURIComponent(descriptor.documentId)}`, window.location.origin); - url.searchParams.set('sourceKind', descriptor.sourceKind || 'convex_workspace'); + url.searchParams.set('sourceKind', descriptor.sourceKind || 'local_folder'); if (descriptor.workspaceId) url.searchParams.set('workspaceId', descriptor.workspaceId); if (descriptor.rootUri) url.searchParams.set('rootUri', descriptor.rootUri); return url; @@ -151,10 +151,10 @@ import { documentId: aggregate?.identity?.documentId || aggregate?.identity?.document_id || descriptor.documentId, workspaceId: aggregate?.identity?.workspaceId || aggregate?.identity?.workspace_id || descriptor.workspaceId || '', paneRole, - sourceKind: descriptor.sourceKind || 'convex_workspace', + sourceKind: descriptor.sourceKind || 'local_folder', rootUri: descriptor.rootUri || '', pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__', - saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder' + saveEndpoint: (descriptor.sourceKind || 'local_folder') === 'local_folder' ? '/api/page-body/write' : '/api/documents/save', titleEndpoint: '/api/documents/title', @@ -225,6 +225,48 @@ import { if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim(); return ''; }; + const currentWebShellSourceKind = () => { + try { + return currentUrl().searchParams.get('sourceKind') || ''; + } catch (_) { + return ''; + } + }; + const currentWebShellRootUri = () => { + try { + return currentUrl().searchParams.get('rootUri') || ''; + } catch (_) { + return ''; + } + }; + + const restoreResourceTabInputFromUrl = () => { + const url = currentUrl(); + const raw = String(url.searchParams.get('resourceTab') || '').trim(); + if (!raw) return null; + const objectIdentity = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw; + if (!objectIdentity.startsWith('resource:file:')) return null; + const rootUri = currentWebShellRootUri(); + if (!rootUri) return null; + const rest = objectIdentity.slice('resource:file:'.length); + const prefix = `${rootUri}:`; + if (!rest.startsWith(prefix)) return null; + const path = rest.slice(prefix.length).replace(/^\/+/, ''); + if (!path) return null; + const title = path.split('/').filter(Boolean).pop() || path; + return { + objectIdentity, + documentId: currentWebShellDocumentId(), + workspaceId: currentWebShellWorkspaceId(), + sourceKind: currentWebShellSourceKind() || 'local_folder', + rootUri, + path, + assetId: `local:asset:${path}`, + title, + fileName: title, + openTarget: 'active-tab', + }; + }; let nextViewId = 1; const mindmapHost = createMindmapHostRuntime({ @@ -267,12 +309,18 @@ import { const markSessionExternalConflict = (...args) => documentSessions.markSessionExternalConflict(...args); const queueSessionSave = (...args) => documentSessions.queueSessionSave(...args); const refreshSessionFromExternalChange = (...args) => documentSessions.refreshSessionFromExternalChange(...args); + const ensureLocalFolderEventChannel = (...args) => documentSessions.ensureLocalFolderEventChannel(...args); const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args); const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args); const updatePaneChrome = (runtimeDescriptor) => { const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); if (!(pane instanceof HTMLElement)) return; + if (runtimeDescriptor.paneRole === 'primary') { + document.querySelectorAll('[data-mnote-navigation-page-placeholder][data-pane-role="primary"]').forEach((node) => { + if (node instanceof HTMLElement) node.hidden = true; + }); + } const aggregate = runtimeDescriptor.aggregate || {}; const bootstrap = runtimeDescriptor.bootstrap || {}; const title = aggregate?.head?.title || '无标题'; @@ -366,9 +414,94 @@ import { return payload.result; }; + const ensureLazyPrimaryPaneRoot = () => { + let root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="primary"]`); + if (root instanceof HTMLElement) return root; + const pagePanel = document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]'); + if (!(pagePanel instanceof HTMLElement)) return null; + const pane = document.createElement('section'); + pane.className = 'document-pane'; + pane.setAttribute('data-document-pane', 'true'); + pane.setAttribute('data-pane-role', 'primary'); + pane.setAttribute('data-pane-document-id', ''); + pane.setAttribute('data-pane-workspace-id', currentWebShellWorkspaceId()); + pane.setAttribute('data-pane-visible', 'false'); + pane.hidden = true; + + const shell = document.createElement('main'); + shell.className = 'document-shell'; + shell.setAttribute('data-editor-host', 'leptos_tiptap_island'); + shell.setAttribute('data-document-id', ''); + shell.setAttribute('data-workspace-id', currentWebShellWorkspaceId()); + shell.setAttribute('data-pane-role', 'primary'); + shell.setAttribute('data-page-wide-layout', 'false'); + shell.setAttribute('data-page-small-text', 'false'); + shell.setAttribute('data-layout-density', 'normal'); + shell.setAttribute('data-page-font', 'default'); + shell.setAttribute('data-page-show-heading-numbers', 'false'); + + const header = document.createElement('header'); + header.className = 'document-shell-header'; + header.setAttribute('data-page-title-hidden', 'true'); + header.hidden = true; + const titleHeading = document.createElement('h1'); + titleHeading.className = 'document-title-heading'; + const titleInput = document.createElement('textarea'); + titleInput.id = 'mnote-page-title-input'; + titleInput.className = 'document-title-input'; + titleInput.setAttribute('aria-label', '页面标题'); + titleInput.setAttribute('data-page-title-input', 'true'); + titleInput.setAttribute('data-document-id', ''); + titleInput.setAttribute('data-workspace-id', currentWebShellWorkspaceId()); + titleInput.setAttribute('data-pane-role', 'primary'); + titleInput.setAttribute('data-title-endpoint', '/api/documents/title'); + titleInput.rows = 1; + titleHeading.appendChild(titleInput); + const meta = document.createElement('div'); + meta.className = 'document-shell-meta'; + meta.setAttribute('aria-label', '页面元信息'); + const currentTitle = document.createElement('span'); + currentTitle.setAttribute('data-page-title-current', 'true'); + meta.appendChild(currentTitle); + header.append(titleHeading, meta); + + const aggregateMarker = document.createElement('section'); + aggregateMarker.setAttribute('data-page-aggregate-snapshot', 'mnote.page_aggregate.v1'); + const subtree = document.createElement('section'); + subtree.setAttribute('data-testid', 'mnote-page-subtree'); + subtree.setAttribute('data-page-tree-source', 'page_aggregate.tree.pageSubtree'); + subtree.setAttribute('data-page-subtree-present', 'false'); + subtree.setAttribute('data-pane-role', 'primary'); + + const island = document.createElement('section'); + island.id = 'mnote-editor-island'; + island.setAttribute('data-editor-host', 'leptos_tiptap_island'); + island.setAttribute('data-pane-role', 'primary'); + root = document.createElement('div'); + root.id = 'mnote-leptos-tiptap-island-editor-root'; + root.setAttribute('data-testid', 'mnote-leptos-tiptap-island-editor-root'); + root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island'); + root.setAttribute('data-runtime-editor-status', 'booting'); + root.setAttribute('data-pane-role', 'primary'); + const observability = document.createElement('div'); + observability.className = 'sr-only'; + observability.setAttribute('data-editor-host-observability', 'rust-web-inline-island'); + observability.setAttribute('data-editor-host-active', 'leptos_tiptap_island'); + observability.setAttribute('data-editor-host-requested', 'leptos_tiptap_island'); + observability.setAttribute('data-editor-host-status', 'booting'); + observability.setAttribute('data-pane-role', 'primary'); + island.append(root, observability); + shell.append(header, aggregateMarker, subtree, island); + pane.appendChild(shell); + pagePanel.appendChild(pane); + return root; + }; + const replacePaneDocument = async (paneRole, descriptor, options = {}) => { mindmapHost.unmountMindmapPane(paneRole); - const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); + const root = paneRole === 'primary' + ? ensureLazyPrimaryPaneRoot() + : document.querySelector(`${ROOT_SELECTOR}[data-pane-role="${paneRole}"]`); const observability = document.querySelector(`[data-editor-host-observability][data-pane-role="${paneRole}"]`); if (!(root instanceof HTMLElement)) throw new Error(`pane_root_missing_${paneRole}`); const runtimePromise = loadRuntime(); @@ -428,14 +561,14 @@ import { return { documentId, workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', - sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'convex_workspace', + sourceKind: explicit.sourceKind || url.searchParams.get('secondarySourceKind') || url.searchParams.get('sourceKind') || 'local_folder', rootUri: explicit.rootUri || url.searchParams.get('secondaryRootUri') || url.searchParams.get('rootUri') || '', }; } return { documentId, workspaceId: explicit.workspaceId || url.searchParams.get('workspaceId') || '', - sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'convex_workspace', + sourceKind: explicit.sourceKind || url.searchParams.get('sourceKind') || 'local_folder', rootUri: explicit.rootUri || url.searchParams.get('rootUri') || '', }; }; @@ -529,6 +662,11 @@ import { broadcastSessionContent(session, view); } if ((pendingExternalChange || recentExternalChange) && (session.dirty || recentLocalInput)) { + if (session.sourceKind === 'local_folder' && session.sessionKind !== 'resource') { + session.externalChangePending = true; + if (session.dirty) queueSessionSave(session); + return; + } markSessionExternalConflict(session, externalConflictMessage); return; } @@ -629,6 +767,9 @@ import { } else { setStatus(runtimeDescriptor, 'ready'); } + if (session.pageBodySource) runtimeDescriptor.root.setAttribute('data-mnote-page-body-source', session.pageBodySource); + if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource); + if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion); enhanceEditorAttachmentLinksSoon(); }; view.onError = (event) => { @@ -674,6 +815,7 @@ import { resourceTabs = createResourceTabRuntime({ loadRuntime, createEditorViewBinding, + ensureLocalFolderEventChannel, unmountEditorViewBinding, setStatus, documentSessionRegistry, @@ -828,7 +970,16 @@ import { }, { once: true }); if (paneRuntimes.length) { - Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => { + Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => { + const restoreInput = restoreResourceTabInputFromUrl(); + if (restoreInput) { + return openResourceInActiveTab(restoreInput).catch((error) => { + console.warn('mnote resource tab URL 恢复失败', error); + return false; + }); + } + return false; + }).catch((error) => { console.error('mnote multi-pane editor mount failed', error); }); } diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index b4a5b47b..7e9feafa 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -1,12 +1,45 @@ import { localMarkdownDocumentIdFromRelativePath, + localMarkdownRelativePathFromDocumentId, localizeTiptapAssetUrls, } from './document-tiptap-conversion-runtime.js'; +/** + * @typedef {Object} MNoteWorkspacePath + * @property {'mnote.workspace_path.v1'} schema + * @property {string} workspaceId + * @property {string} sourceKind + * @property {string} rootUri + * @property {string} relativePath + * @property {string} documentId + * @property {string} objectIdentity + * @property {string} assetId + * @property {string} resourceKind + * + * @typedef {Object} MNoteOpenEditorEntry + * @property {string} objectIdentity + * @property {MNoteWorkspacePath|null} workspacePath + * @property {'primary'|'secondary'} paneRole + * @property {string} editorKind + * @property {boolean} active + * @property {string} dirtyState + * @property {boolean} preview + * @property {boolean} pinned + * @property {number} lastActiveAt + * + * @typedef {Object} MNoteOpenEditorsSnapshot + * @property {'mnote.open_editors_snapshot.v1'} schema + * @property {number} generatedAt + * @property {string} activeObjectIdentity + * @property {MNoteOpenEditorEntry[]} editors + * @property {{primary: Object, secondary: Object}} groups + */ + export const createResourceTabRuntime = (dependencies = {}) => { const { loadRuntime, createEditorViewBinding, + ensureLocalFolderEventChannel, unmountEditorViewBinding, setStatus, documentSessionRegistry, @@ -62,9 +95,31 @@ export const createResourceTabRuntime = (dependencies = {}) => { return 'file'; }; + const resourceTabWidthType = (entry) => { + if (!entry) return ''; + const kind = String(entry.kind || '').trim(); + if (kind === 'office') { + const badgeKind = String(entry.badgeKind || resourceTabBadgeKind(entry, kind) || '').trim(); + if (badgeKind === 'ppt') return 'ppt'; + if (badgeKind === 'sheet') return 'excel'; + return 'word'; + } + if (kind === 'pdf') return 'pdf'; + if (kind === 'mindmap') return 'mindmap'; + if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'markdown'; + return ''; + }; + const currentWebShellWorkspaceId = () => { const explicit = typeof currentWorkspaceId === 'function' ? String(currentWorkspaceId() || '').trim() : ''; if (explicit) return explicit; + const fromBody = document.body?.dataset?.workspaceId || ''; + if (fromBody) return String(fromBody).trim(); + const shell = document.querySelector('.document-shell[data-workspace-id], [data-document-pane="true"][data-pane-role="primary"][data-pane-workspace-id]'); + if (shell instanceof HTMLElement) { + const value = shell.getAttribute('data-workspace-id') || shell.getAttribute('data-pane-workspace-id') || ''; + if (value) return value.trim(); + } try { return currentUrl().searchParams.get('workspaceId') || ''; } catch (_) { @@ -82,6 +137,80 @@ export const createResourceTabRuntime = (dependencies = {}) => { return ''; }; + const documentIdForPane = (paneRole) => { + const role = normalizePaneRole(paneRole); + const nodes = resourceTabHostNodes(role); + const fromTab = String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(); + if (fromTab) return fromTab; + const pane = document.querySelector(`[data-document-pane="true"][data-pane-role="${role}"]`); + if (pane instanceof HTMLElement) { + const fromPane = String(pane.getAttribute('data-pane-document-id') || '').trim(); + if (fromPane) return fromPane; + const root = pane.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-document-id], .document-shell[data-document-id]'); + if (root instanceof HTMLElement) { + const fromRoot = String(root.getAttribute('data-document-id') || '').trim(); + if (fromRoot) return fromRoot; + } + } + return role === 'primary' ? currentWebShellDocumentId() : ''; + }; + + const currentWebShellSourceKind = () => { + try { + return currentUrl().searchParams.get('sourceKind') || ''; + } catch (_) { + return ''; + } + }; + + const currentWebShellRootUri = () => { + try { + return currentUrl().searchParams.get('rootUri') || ''; + } catch (_) { + return ''; + } + }; + + const buildWorkspacePath = ({ + workspacePath, + workspaceId, + sourceKind, + rootUri, + relativePath, + documentId, + objectIdentity, + assetId, + resourceKind, + } = {}) => { + const fromInput = workspacePath && typeof workspacePath === 'object' ? workspacePath : null; + if (fromInput && String(fromInput.schema || '') === 'mnote.workspace_path.v1') { + return { + schema: 'mnote.workspace_path.v1', + workspaceId: String(fromInput.workspaceId || workspaceId || '').trim(), + sourceKind: String(fromInput.sourceKind || sourceKind || '').trim(), + rootUri: String(fromInput.rootUri || rootUri || '').trim(), + relativePath: String(fromInput.relativePath || fromInput.localRelativePath || relativePath || '').trim(), + documentId: String(fromInput.documentId || documentId || '').trim(), + objectIdentity: fromInput.objectIdentity ?? String(objectIdentity || '').trim(), + assetId: String(fromInput.assetId || assetId || '').trim(), + resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(), + }; + } + const id = String(objectIdentity || documentId || assetId || relativePath || '').trim(); + if (!id) return null; + return { + schema: 'mnote.workspace_path.v1', + workspaceId: String(workspaceId || '').trim(), + sourceKind: String(sourceKind || '').trim(), + rootUri: String(rootUri || '').trim(), + relativePath: String(relativePath || '').trim(), + documentId: String(documentId || '').trim(), + objectIdentity: String(objectIdentity || '').trim(), + assetId: String(assetId || '').trim(), + resourceKind: String(resourceKind || '').trim(), + }; + }; + const normalizeResourceTabKind = (input) => { const kind = String(input?.kind || '').trim().toLowerCase(); const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase(); @@ -121,7 +250,10 @@ export const createResourceTabRuntime = (dependencies = {}) => { const setResourceTabLastActive = (key) => { const entry = resourceTabRegistry.get(String(key || '').trim()); - if (entry?.session) entry.session.lastActiveAt = Date.now(); + if (!entry) return; + const timestamp = Date.now(); + entry.lastActiveAt = timestamp; + if (entry.session) entry.session.lastActiveAt = timestamp; }; const removeFromResourceTabMru = (paneRole, key) => { @@ -140,6 +272,10 @@ export const createResourceTabRuntime = (dependencies = {}) => { const resourceTabCloseGuardReason = (session) => { if (!session) return ''; + const bufferDirtyState = String(session.bufferDirtyState || '').trim(); + if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') { + return bufferDirtyState; + } const hasUnsavedChanges = session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session) @@ -150,7 +286,49 @@ export const createResourceTabRuntime = (dependencies = {}) => { return ''; }; - const syncResourceTabCloseGuard = (entry) => { + const documentSessionForPane = (paneRole, documentId) => { + const id = String(documentId || '').trim(); + if (!id || !documentSessionRegistry || typeof documentSessionRegistry.values !== 'function') return null; + const role = normalizePaneRole(paneRole); + return Array.from(documentSessionRegistry.values()).find((session) => ( + session + && session.sessionKind !== 'resource' + && String(session.documentId || '').trim() === id + && Array.from(session.views?.values?.() || []).some((view) => normalizePaneRole(view?.runtimeDescriptor?.paneRole) === role) + )) || Array.from(documentSessionRegistry.values()).find((session) => ( + session + && session.sessionKind !== 'resource' + && String(session.documentId || '').trim() === id + )) || null; + }; + + const documentSessionDirtyState = (session) => { + if (!session) return ''; + const bufferDirtyState = String(session.bufferDirtyState || '').trim(); + if (bufferDirtyState === 'Dirty' || bufferDirtyState === 'Stale' || bufferDirtyState === 'Deleted' || bufferDirtyState === 'ExternalModified') return bufferDirtyState; + if (session.hasExternalConflict) return 'ExternalModified'; + if (session.dirty || Boolean(session.saveTimer) || sessionHasRecentLocalInput(session)) return 'Dirty'; + if (session.saving) return 'Saving'; + return ''; + }; + + const resourceTabBufferStateUrl = (entry) => { + const session = entry?.session; + if (!session || session.sourceKind !== 'local_folder') return null; + const relativePath = String(session.resourcePath || entry?.workspacePath?.relativePath || '').trim(); + const rootUri = String(session.rootUri || entry?.workspacePath?.rootUri || '').trim(); + if (!relativePath || !rootUri) return null; + const url = new URL('/api/documents/buffer-state', window.location.origin); + url.searchParams.set('documentId', String(session.documentId || entry.objectIdentity || '')); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('relativePath', relativePath); + const workspaceId = String(session.workspaceId || entry?.workspacePath?.workspaceId || '').trim(); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + return url; + }; + + const applyResourceTabCloseGuard = (entry) => { if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return; const reason = resourceTabCloseGuardReason(entry.session); if (reason) { @@ -162,6 +340,30 @@ export const createResourceTabRuntime = (dependencies = {}) => { } }; + const refreshResourceTabBufferState = async (entry) => { + const url = resourceTabBufferStateUrl(entry); + if (!url || !entry?.session) return null; + try { + const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true || !payload.result) return null; + const dirtyState = String(payload.result.dirtyState || '').trim(); + if (dirtyState) entry.session.bufferDirtyState = dirtyState; + if (typeof payload.result.fileVersion === 'string' && payload.result.fileVersion.trim()) { + entry.session.fileVersion = payload.result.fileVersion.trim(); + } + applyResourceTabCloseGuard(entry); + return payload.result; + } catch (_) { + return null; + } + }; + + const syncResourceTabCloseGuard = (entry) => { + applyResourceTabCloseGuard(entry); + void refreshResourceTabBufferState(entry); + }; + const syncResourceSessionTabGuards = (session) => { if (!session || session.sessionKind !== 'resource') return; resourceTabRegistry.forEach((entry) => { @@ -169,52 +371,126 @@ export const createResourceTabRuntime = (dependencies = {}) => { }); }; - const openEditorsSnapshotEntry = (entry, key) => ({ - objectIdentity: String(entry?.objectIdentity || key || '').trim(), - title: String(entry?.title || '资源').trim() || '资源', - kind: normalizeResourceTabKind(entry), - badgeKind: entry?.tab instanceof HTMLElement - ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() - : resourceTabBadgeKind(entry, entry?.kind), - active: entry?.tab instanceof HTMLElement + const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => { + const active = entry?.tab instanceof HTMLElement ? entry.tab.getAttribute('aria-selected') === 'true' - : false, - dirtyGuard: resourceTabCloseGuardReason(entry?.session), - assetId: String(entry?.assetId || entry?.session?.assetId || '').trim(), - path: String(entry?.path || entry?.session?.resourcePath || '').trim(), - }); + : false; + const documentId = String(entry?.documentId || entry?.ownerDocumentId || entry?.session?.ownerDocumentId || '').trim(); + const workspaceId = String(entry?.workspaceId || entry?.session?.workspaceId || currentWebShellWorkspaceId() || '').trim(); + const sourceKind = String(entry?.sourceKind || entry?.session?.sourceKind || currentWebShellSourceKind() || '').trim(); + const rootUri = String(entry?.rootUri || entry?.session?.rootUri || currentWebShellRootUri() || '').trim(); + const relativePath = String(entry?.path || entry?.session?.resourcePath || '').trim(); + const objectIdentity = String(entry?.objectIdentity || key || '').trim(); + const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim(); + const kind = normalizeResourceTabKind(entry); + const dirtyState = resourceTabCloseGuardReason(entry?.session); + return { + objectIdentity, + workspacePath: buildWorkspacePath({ + workspaceId, + sourceKind, + rootUri, + relativePath, + documentId, + objectIdentity, + assetId, + resourceKind: kind, + workspacePath: entry?.workspacePath, + }), + paneRole: normalizePaneRole(entry?.paneRole), + documentId, + workspaceId, + title: String(entry?.title || '资源').trim() || '资源', + kind, + editorKind: kind, + badgeKind: entry?.tab instanceof HTMLElement + ? String(entry.tab.getAttribute('data-mnote-tab-badge-kind') || resourceTabBadgeKind(entry, entry.kind)).trim() + : resourceTabBadgeKind(entry, entry?.kind), + active, + dirtyState, + dirtyGuard: dirtyState, + assetId, + path: relativePath, + lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0), + preview: false, + pinned: false, + }; + }; const buildOpenEditorsSnapshot = () => { + const generatedAt = Date.now(); const pageEntries = ['primary', 'secondary'].map((paneRole) => { const nodes = resourceTabHostNodes(paneRole); + const documentId = documentIdForPane(paneRole); + const workspaceId = String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(); + const sourceKind = currentWebShellSourceKind(); + const rootUri = currentWebShellRootUri(); + const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : ''; + const objectIdentity = `page:${paneRole}`; + const active = nodes.pageTab instanceof HTMLElement + ? nodes.pageTab.getAttribute('aria-selected') === 'true' + : false; + const pageSession = documentSessionForPane(paneRole, documentId); + const dirtyState = documentSessionDirtyState(pageSession); const pageTitle = nodes.pageTab instanceof HTMLElement ? String(nodes.pageTab.querySelector('.mnote-main-tab-title')?.textContent || '页面').trim() : '页面'; return { - objectIdentity: `page:${paneRole}`, + objectIdentity, + workspacePath: buildWorkspacePath({ + workspaceId, + sourceKind, + rootUri, + relativePath, + documentId, + objectIdentity, + assetId: '', + resourceKind: 'page', + }), paneRole, - documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || '').trim(), - workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(), + documentId, + workspaceId, title: pageTitle || '页面', kind: 'page', + editorKind: 'page', badgeKind: 'code', - active: nodes.pageTab instanceof HTMLElement - ? nodes.pageTab.getAttribute('aria-selected') === 'true' - : false, - dirtyGuard: '', + active, + dirtyState, + dirtyGuard: dirtyState, + lastActiveAt: active ? generatedAt : 0, + preview: false, + pinned: true, }; }).filter((entry) => entry.paneRole === 'primary' || entry.documentId || document.querySelector('[data-document-pane="true"][data-pane-role="secondary"][data-pane-visible="true"]')); const resources = []; resourceTabRegistry.forEach((entry, key) => { - resources.push(openEditorsSnapshotEntry(entry, key)); + resources.push(openEditorsSnapshotEntry(entry, key, generatedAt)); }); - const active = [...pageEntries, ...resources].find((entry) => entry.active); + const editors = [...pageEntries, ...resources]; + const groupForPane = (paneRole) => { + const role = normalizePaneRole(paneRole); + const groupEditors = editors.filter((entry) => normalizePaneRole(entry.paneRole) === role); + const groupResources = resources.filter((entry) => normalizePaneRole(entry.paneRole) === role); + const groupActive = groupEditors.find((entry) => entry.active); + return { + paneRole: role, + activeObjectIdentity: groupActive?.objectIdentity || '', + editors: groupEditors, + resourceEditors: groupResources, + }; + }; + const groups = { + primary: groupForPane('primary'), + secondary: groupForPane('secondary'), + }; + const activeObjectIdentity = groups.primary.activeObjectIdentity || groups.secondary.activeObjectIdentity || ''; return { schema: 'mnote.open_editors_snapshot.v1', - generatedAt: Date.now(), - activeObjectIdentity: active?.objectIdentity || '', - editors: [...pageEntries, ...resources], + generatedAt, + activeObjectIdentity, + editors, resourceEditors: resources, + groups, }; }; @@ -295,6 +571,29 @@ export const createResourceTabRuntime = (dependencies = {}) => { } }; + const syncActiveResourceWidthType = (activeEntry, paneRole = 'primary') => { + const role = normalizePaneRole(paneRole); + const widthType = role === 'primary' ? resourceTabWidthType(activeEntry) : ''; + const targetNodes = [document.documentElement, document.body].filter((node) => node instanceof HTMLElement); + targetNodes.forEach((node) => { + if (widthType) node.setAttribute('data-mnote-active-resource-width-type', widthType); + else node.removeAttribute('data-mnote-active-resource-width-type'); + }); + const shell = document.querySelector(`.document-pane[data-pane-role="${role}"] .document-shell`); + if (shell instanceof HTMLElement) { + if (widthType) shell.setAttribute('data-mnote-active-resource-width-type', widthType); + else shell.removeAttribute('data-mnote-active-resource-width-type'); + } + window.dispatchEvent(new CustomEvent('mnote:active-resource-tab-changed', { + detail: { + paneRole: role, + active: Boolean(activeEntry), + widthType, + objectIdentity: String(activeEntry?.objectIdentity || '') + } + })); + }; + const syncActiveResourceUrlState = (activeResource, paneRole = 'primary') => { const role = normalizePaneRole(paneRole); if (role !== 'primary') return; @@ -340,6 +639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active; syncResourceTabCloseGuard(entry); }); + syncActiveResourceWidthType(activeEntry, role); if (activeEntry) markIntendedSlashRoot(activeEntry); if (!activeEntry && window.__mnoteIntendedSlashRoot instanceof HTMLElement) window.__mnoteIntendedSlashRoot = null; if (role === 'primary') syncActiveResourceFileTreeRow(activeResource); @@ -399,10 +699,11 @@ export const createResourceTabRuntime = (dependencies = {}) => { syncOpenEditorsSnapshot(); }; - const closeResourceTab = (objectIdentity) => { + const closeResourceTab = async (objectIdentity) => { const key = String(objectIdentity || '').trim(); const entry = resourceTabRegistry.get(key); if (!entry) return; + await refreshResourceTabBufferState(entry); const guardReason = resourceTabCloseGuardReason(entry.session); if (guardReason) { console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`); @@ -489,6 +790,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { paneRole, title, kind, + badgeKind: resourceTabBadgeKind(input, kind), tab, panel, view: null, @@ -497,6 +799,21 @@ export const createResourceTabRuntime = (dependencies = {}) => { path: String(input.path || '').trim(), documentId: String(input.documentId || '').trim(), ownerDocumentId: String(input.ownerDocumentId || input.documentId || '').trim(), + workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || '').trim(), + sourceKind: String(input.sourceKind || currentWebShellSourceKind() || '').trim(), + rootUri: String(input.rootUri || currentWebShellRootUri() || '').trim(), + workspacePath: buildWorkspacePath({ + workspacePath: input.workspacePath, + workspaceId: input.workspaceId || currentWebShellWorkspaceId(), + sourceKind: input.sourceKind || currentWebShellSourceKind(), + rootUri: input.rootUri || currentWebShellRootUri(), + relativePath: input.path, + documentId: input.documentId, + objectIdentity, + assetId: input.assetId, + resourceKind: kind, + }), + lastActiveAt: 0, }; }; @@ -515,6 +832,12 @@ export const createResourceTabRuntime = (dependencies = {}) => { } entry.mindmapRuntime = null; } + if (entry.resourceWatchEventSource) { + try { + entry.resourceWatchEventSource.close(); + } catch (_) {} + entry.resourceWatchEventSource = null; + } if (entry.panel instanceof HTMLElement) entry.panel.replaceChildren(); }; @@ -525,6 +848,80 @@ export const createResourceTabRuntime = (dependencies = {}) => { return url.toString(); }; + const localResourceStatUrl = (rootUri, path) => { + const url = new URL('/api/local-folder/files/stat', window.location.origin); + url.searchParams.set('rootUri', rootUri || ''); + url.searchParams.set('path', path || ''); + return url.toString(); + }; + + const withResourceReloadToken = (href) => { + if (!href) return href; + const url = new URL(href, window.location.origin); + url.searchParams.set('mnoteResourceReload', String(Date.now())); + return url.toString(); + }; + + const renderPassiveResourceMissing = (entry, message) => { + if (!(entry?.panel instanceof HTMLElement)) return; + entry.panel.setAttribute('data-mnote-resource-missing', 'true'); + entry.panel.innerHTML = '

资源已不可用

'; + const text = entry.panel.querySelector('p'); + if (text instanceof HTMLElement) text.textContent = message || '本地资源文件已被删除或移动。'; + }; + + const reloadPassiveResourceTab = (entry) => { + if (!(entry?.panel instanceof HTMLElement)) return; + entry.panel.removeAttribute('data-mnote-resource-missing'); + const img = entry.panel.querySelector('img.mnote-resource-tab-image'); + if (img instanceof HTMLImageElement) { + img.src = withResourceReloadToken(img.getAttribute('src') || img.src || ''); + return; + } + const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame'); + if (frame instanceof HTMLIFrameElement) { + frame.src = withResourceReloadToken(frame.getAttribute('src') || frame.src || ''); + } + }; + + const installPassiveResourceWatch = (entry) => { + if (!entry || entry.session || typeof window.EventSource !== 'function') return; + const rootUri = String(entry.rootUri || '').trim(); + const path = String(entry.path || '').trim(); + if (!rootUri || !path) return; + if (entry.resourceWatchEventSource) { + try { + entry.resourceWatchEventSource.close(); + } catch (_) {} + entry.resourceWatchEventSource = null; + } + const url = new URL('/api/local-folder/events', window.location.origin); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('resourcePath', path); + const eventSource = new EventSource(url.toString()); + entry.resourceWatchEventSource = eventSource; + eventSource.addEventListener('ready', () => { + if (entry.panel instanceof HTMLElement) entry.panel.setAttribute('data-mnote-resource-watch-ready', 'true'); + }); + eventSource.addEventListener('change', async () => { + try { + const response = await fetch(localResourceStatUrl(rootUri, path), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + const exists = Boolean(response.ok && payload?.ok === true && payload?.result?.exists); + if (!exists) { + renderPassiveResourceMissing(entry, '本地资源文件已被删除或移动。'); + return; + } + reloadPassiveResourceTab(entry); + } catch (error) { + renderPassiveResourceMissing(entry, error instanceof Error ? error.message : String(error)); + } + }); + }; + const createResourceSession = (entry, input, readResult) => { const resourcePath = String(input.path || ''); const tiptapDocument = localizeTiptapAssetUrls( @@ -577,6 +974,9 @@ export const createResourceTabRuntime = (dependencies = {}) => { error: null, }; documentSessionRegistry.set(session.key, session); + if (typeof ensureLocalFolderEventChannel === 'function') { + ensureLocalFolderEventChannel(session); + } return session; }; @@ -644,6 +1044,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { img.src = href; img.alt = entry.title; } + installPassiveResourceWatch(entry); return; } entry.panel.innerHTML = ''; @@ -652,6 +1053,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { frame.title = entry.title; frame.src = href; } + installPassiveResourceWatch(entry); }; const refreshExistingOfficeResourceTab = (entry, input) => { diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 40050a4a..1f561880 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -5,6 +5,7 @@ import { hydrateMindmapAttrsFromDom, legacyBlocksFromEditorDocument, pageBodyTiptapDocument, + pageBodyTiptapDocumentSource, revisionFromConflictKey, textToTiptapDocument, toTiptapDocument, @@ -102,7 +103,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const normalizeSessionSourceKind = (bootstrap) => { const value = typeof bootstrap?.sourceKind === 'string' ? bootstrap.sourceKind.trim() : ''; - return value || 'convex_workspace'; + return value || 'local_folder'; }; const buildDocumentSessionKey = (bootstrap) => { @@ -115,7 +116,90 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const sessionViews = (session) => Array.from(session.views.values()); - const localFolderEventChannelKey = (session) => `${String(session?.rootUri || '').trim()}#${String(session?.documentId || '').trim()}`; + const resourceWatchPath = (session) => String(session?.resourcePath || session?.relativePath || '').trim(); + + const localFolderEventChannelKey = (session) => { + const scope = session?.sessionKind === 'resource' + ? `resource:${resourceWatchPath(session)}` + : String(session?.documentId || '').trim(); + return `${String(session?.rootUri || '').trim()}#${scope}`; + }; + + const localMarkdownRelativePathFromDocumentId = (documentId) => { + const value = String(documentId || '').trim(); + if (!value.startsWith('local-md:')) return ''; + return value.slice('local-md:'.length).replace(/~2F/g, '/'); + }; + + const sessionRelativePath = (session) => ( + String(session?.relativePath || '').trim() + || localMarkdownRelativePathFromDocumentId(session?.documentId) + ); + + const sessionBufferStateUrl = (session) => { + if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null; + const url = new URL('/api/documents/buffer-state', window.location.origin); + url.searchParams.set('documentId', session.documentId); + url.searchParams.set('sourceKind', session.sourceKind); + url.searchParams.set('rootUri', session.rootUri); + if (session.workspaceId) url.searchParams.set('workspaceId', session.workspaceId); + const relativePath = sessionRelativePath(session); + if (relativePath) url.searchParams.set('relativePath', relativePath); + return url; + }; + + const applyBufferStateToSession = (session, bufferState) => { + if (!session || !bufferState || typeof bufferState !== 'object') return; + const dirtyState = String(bufferState.dirtyState || '').trim(); + if (dirtyState) session.bufferDirtyState = dirtyState; + if (typeof bufferState.fileVersion === 'string' && bufferState.fileVersion.trim()) { + session.fileVersion = bufferState.fileVersion.trim(); + if (!session.conflictDetectionKey) session.conflictDetectionKey = session.fileVersion; + } + if (typeof bufferState.externalActor === 'string' && bufferState.externalActor.trim()) { + session.lastExternalWriteSource = bufferState.externalActor.trim(); + } + }; + + const fetchSessionBufferState = async (session) => { + const url = sessionBufferStateUrl(session); + if (!url) return null; + try { + const response = await fetch(url.toString(), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || payload.ok !== true) return null; + const bufferState = payload.result || null; + applyBufferStateToSession(session, bufferState); + return bufferState; + } catch (_error) { + return null; + } + }; + + const markSessionBufferDirty = (session) => { + if (!session || session.sourceKind !== 'local_folder' || session.sessionKind === 'resource') return; + const relativePath = sessionRelativePath(session); + if (!relativePath) return; + const serialized = String(session.currentSerialized || ''); + const contentHash = `browser-dirty:${serialized.length}:${serialized.charCodeAt(0) || 0}:${serialized.charCodeAt(serialized.length - 1) || 0}`; + void fetch('/api/documents/buffer-state/dirty', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ + documentId: session.documentId, + workspaceId: session.workspaceId, + sourceKind: session.sourceKind, + rootUri: session.rootUri, + relativePath, + contentHash, + }), + }).then((response) => response.json().catch(() => null)).then((payload) => { + if (payload && payload.ok === true) applyBufferStateToSession(session, payload.result); + }).catch(() => undefined); + }; const detachSessionFromLocalFolderChannel = (session) => { const channel = session.localFolderChannel; @@ -416,16 +500,21 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const ensureSessionConflictEnvelope = (session, message) => { if (!session) return null; if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope; + const isResourceSession = session.sessionKind === 'resource'; + const defaultMessage = isResourceSession + ? '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突' + : externalConflictMessage; const envelope = { - code: 'local_markdown_external_change', + code: isResourceSession ? 'local_resource_external_change' : 'local_markdown_external_change', documentId: session.documentId, rootUri: session.rootUri || null, + path: isResourceSession ? (session.resourcePath || '') : undefined, currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null, editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null, externalActor: session.lastExternalWriteSource || null, dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'), bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null, - message: message || externalConflictMessage, + message: isResourceSession && (!message || message === externalConflictMessage) ? defaultMessage : (message || defaultMessage), suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'], }; session.lastExternalConflictEnvelope = envelope; @@ -802,13 +891,13 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const markSessionExternalConflict = (session, message) => { session.externalChangePending = false; - ensureSessionConflictEnvelope(session, message); + const envelope = ensureSessionConflictEnvelope(session, message); session.hasExternalConflict = true; if (session.saveTimer) { window.clearTimeout(session.saveTimer); session.saveTimer = 0; } - const nextMessage = message || externalConflictMessage; + const nextMessage = envelope?.message || message || externalConflictMessage; setSessionStatus(session, 'external-change-conflict', nextMessage); renderSessionConflictSurface(session, nextMessage); }; @@ -817,12 +906,23 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { if (session.readOnly || session.hasExternalConflict) return; if (session.saveTimer) window.clearTimeout(session.saveTimer); setSessionStatus(session, 'dirty'); + markSessionBufferDirty(session); session.saveTimer = window.setTimeout(() => { session.saveTimer = 0; void persistSession(session); }, 650); }; + const makeSaveOperationToken = (prefix, session) => { + const doc = String(session?.documentId || session?.resourcePath || 'unknown') + .replace(/[^a-zA-Z0-9._:-]+/g, '-') + .slice(0, 80); + const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `${prefix}:${doc}:${random}`; + }; + const persistSession = async (session) => { if (session.readOnly || session.saving || session.hasExternalConflict) return; const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0]; @@ -841,11 +941,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument); const content = legacyBlocksFromEditorDocument(editorDocument); const saveEndpoint = session.saveEndpoint || '/api/documents/save'; + const expectedFileVersion = ( + session.sourceKind === 'local_folder' + && !session.lastExternalConflictEnvelope + && session.lastExternalConflictDetectionKey + ) + ? session.lastExternalConflictDetectionKey + : session.conflictDetectionKey; + if (session.sourceKind === 'local_folder' && expectedFileVersion && expectedFileVersion !== session.conflictDetectionKey) { + session.conflictDetectionKey = expectedFileVersion; + session.fileVersion = expectedFileVersion; + } + const writeIntentId = session.sourceKind === 'local_folder' + ? makeSaveOperationToken('intent:tiptap', session) + : ''; + const saveOperationId = session.sourceKind === 'local_folder' + ? makeSaveOperationToken('save:tiptap', session) + : ''; const savePayload = session.sessionKind === 'resource' ? { rootUri: session.rootUri, path: session.resourcePath, - expectedFileVersion: session.conflictDetectionKey, + expectedFileVersion: expectedFileVersion, + writeIntentId, + saveOperationId, contentFormat: 'editorBlocks', editorSource: 'tiptap-resource-tab', editorDocument, @@ -859,7 +978,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { sourceKind: session.sourceKind, rootUri: session.rootUri, revision: session.revision, - expectedFileVersion: session.conflictDetectionKey, + expectedFileVersion: expectedFileVersion, + writeIntentId, + saveOperationId, contentFormat: 'editorBlocks', editorSource: 'tiptap', editorDocument, @@ -889,6 +1010,16 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { throw new Error(message); } const saved = result.result || {}; + if (typeof saved.writeIntentId === 'string' && saved.writeIntentId.trim()) { + session.lastWriteIntentId = saved.writeIntentId.trim(); + } else if (writeIntentId) { + session.lastWriteIntentId = writeIntentId; + } + if (typeof saved.saveOperationId === 'string' && saved.saveOperationId.trim()) { + session.lastSaveOperationId = saved.saveOperationId.trim(); + } else if (saveOperationId) { + session.lastSaveOperationId = saveOperationId; + } if (Number.isInteger(saved.revision)) session.revision = saved.revision; if (typeof saved.conflict_detection_key === 'string' && saved.conflict_detection_key.trim()) { session.conflictDetectionKey = saved.conflict_detection_key.trim(); @@ -902,6 +1033,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey; session.hasExternalConflict = false; session.externalChangePending = false; + session.bufferDirtyState = 'Clean'; session.lastUserInputAt = 0; syncSessionMetaToViews(session); session.lastPersistedSerialized = serialized; @@ -951,7 +1083,47 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { if (document.hidden) return; if (!session || session.views.size === 0) return; if (session.sourceKind === 'local_folder' && !session.rootUri) return; + if (session.sessionKind === 'resource') { + await refreshResourceSessionFromExternalChange(session, source); + return; + } try { + const bufferState = await fetchSessionBufferState(session); + const dirtyState = String(bufferState?.dirtyState || session.bufferDirtyState || '').trim(); + if (dirtyState === 'Deleted') { + session.lastExternalConflictEnvelope = { + code: 'local_markdown_deleted', + documentId: session.documentId, + rootUri: session.rootUri || null, + currentDiskVersion: bufferState?.fileVersion || session.conflictDetectionKey || session.fileVersion || null, + editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, + externalActor: bufferState?.externalActor || null, + dirtyState, + bufferFileVersion: bufferState?.fileVersion || session.fileVersion || null, + message: '当前本地 Markdown 文件已被删除,请恢复文件或关闭当前标签', + suggestedActions: ['keep_editor', 'open_diff'], + }; + markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); + return; + } + if (session.hasExternalConflict) { + markSessionExternalConflict(session, conflictMessageFromEnvelope(session, { + message: externalConflictMessage, + dirtyState, + fileVersion: bufferState?.fileVersion || null, + externalActor: bufferState?.externalActor || null, + })); + return; + } + if (sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + session.externalChangePending = true; + if (session.dirty && !session.saveTimer && !session.saving) { + queueSessionSave(session); + } else if (!session.saving) { + setSessionStatus(session, 'dirty'); + } + return; + } const response = await fetch(pageAggregateUrl({ documentId: session.documentId, sourceKind: session.sourceKind, @@ -999,13 +1171,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const nextSerialized = JSON.stringify(nextTiptapDocument); const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; + session.bufferDirtyState = 'Clean'; if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; if (!contentChanged) return; } - if (nextConflictKey === session.lastExternalConflictDetectionKey && !contentChanged) return; - if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { - markSessionExternalConflict(session, externalConflictMessage); + if (nextConflictKey === session.lastExternalConflictDetectionKey) { + if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) { + session.conflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + syncSessionMetaToViews(session); + } return; } const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); @@ -1031,6 +1207,78 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { } }; + const refreshResourceSessionFromExternalChange = async (session, source) => { + if (document.hidden) return; + if (!session || session.views.size === 0) return; + if (session.sourceKind !== 'local_folder' || !session.rootUri || !session.resourcePath) return; + let nextResource = null; + try { + nextResource = await fetchLatestResourceSnapshot(session); + } catch (error) { + session.lastExternalConflictEnvelope = { + code: 'local_resource_deleted', + documentId: session.documentId, + rootUri: session.rootUri || null, + path: session.resourcePath || '', + currentDiskVersion: null, + editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, + externalActor: 'external-editor', + dirtyState: 'Deleted', + message: '当前本地资源文件已被删除或移动,请恢复文件或关闭当前标签', + suggestedActions: ['keep_editor', 'open_diff'], + }; + markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); + return; + } + const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim(); + const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || ''); + const nextSerialized = JSON.stringify(nextTiptapDocument); + const contentChanged = nextSerialized !== session.currentSerialized; + session.externalChangePending = false; + if (!nextConflictKey || !session.lastExternalConflictDetectionKey) { + session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey; + if (!contentChanged) return; + } + if (nextConflictKey === session.lastExternalConflictDetectionKey) { + if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) { + session.conflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + syncSessionMetaToViews(session); + } + return; + } + if (session.hasExternalConflict || sessionHasRecentLocalInput(session) || session.dirty || session.saveTimer || session.saving) { + session.lastExternalConflictEnvelope = { + code: 'local_resource_external_change', + documentId: session.documentId, + rootUri: session.rootUri || null, + path: session.resourcePath || '', + currentDiskVersion: nextConflictKey || null, + editorBaseVersion: session.conflictDetectionKey || session.fileVersion || null, + externalActor: 'external-editor', + dirtyState: session.dirty ? 'Dirty' : 'Clean', + message: '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突', + suggestedActions: ['accept_disk', 'keep_editor', 'open_diff'], + }; + markSessionExternalConflict(session, session.lastExternalConflictEnvelope.message); + return; + } + session.currentTiptapDocument = nextTiptapDocument; + session.currentSerialized = nextSerialized; + session.lastPersistedSerialized = nextSerialized; + session.conflictDetectionKey = nextConflictKey; + session.fileVersion = nextConflictKey; + session.lastExternalConflictDetectionKey = nextConflictKey || ''; + session.dirty = false; + session.hasExternalConflict = false; + session.lastUserInputAt = 0; + sessionViews(session).forEach((view) => { + if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-watch'); + }); + setSessionStatus(session, 'synced-external-change'); + syncResourceSessionTabGuards(session); + }; + const refreshSessionFromExternalFileChange = async (session) => { if (session.sourceKind !== 'local_folder') return; await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch'); @@ -1045,12 +1293,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { if (!channel) { const url = new URL('/api/local-folder/events', window.location.origin); url.searchParams.set('rootUri', session.rootUri); - url.searchParams.set('documentId', session.documentId); + if (session.sessionKind === 'resource') { + url.searchParams.set('resourcePath', resourceWatchPath(session)); + } else { + url.searchParams.set('documentId', session.documentId); + } const eventSource = new EventSource(url.toString()); channel = { key: channelKey, rootUri: session.rootUri, documentId: session.documentId, + resourcePath: session.sessionKind === 'resource' ? resourceWatchPath(session) : '', eventSource, sessions: new Map(), }; @@ -1060,6 +1313,19 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { Array.from(channel.sessions.values()).forEach((targetSession) => { if (!targetSession || targetSession.views.size === 0) return; const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : ''; + const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : ''; + if (targetSession.sessionKind === 'resource') { + if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return; + if (targetSession.saving) { + targetSession.externalChangePending = false; + targetSession.lastSelfSaveSignalAt = Date.now(); + return; + } + targetSession.lastExternalChangeSignalAt = Date.now(); + targetSession.externalChangePending = true; + scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch'); + return; + } if (!documentId) return; const eventKind = String(payload.eventKind || ''); const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId); @@ -1076,10 +1342,6 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { } targetSession.lastExternalChangeSignalAt = Date.now(); targetSession.externalChangePending = true; - if (targetsCurrentDocument && (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving)) { - markSessionExternalConflict(targetSession, externalConflictMessage); - return; - } scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch'); }); }); @@ -1277,11 +1539,67 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { return scheduled; }; + const applyLocalUploadEditorSave = (detail) => { + const documentId = String(detail?.documentId || '').trim(); + const rootUri = String(detail?.rootUri || '').trim(); + const fileVersion = String(detail?.fileVersion || '').trim(); + const writeIntentId = String(detail?.writeIntentId || '').trim(); + const saveOperationId = String(detail?.saveOperationId || '').trim(); + const serialized = String(detail?.serialized || ''); + if (!documentId || !rootUri || !fileVersion) return 0; + let applied = 0; + Array.from(documentSessionRegistry.values()).forEach((session) => { + if (session.sourceKind !== 'local_folder') return; + if (session.documentId !== documentId) return; + if (String(session.rootUri || '').trim() !== rootUri) return; + if (session.saveTimer) { + window.clearTimeout(session.saveTimer); + session.saveTimer = 0; + } + session.conflictDetectionKey = fileVersion; + session.fileVersion = fileVersion; + session.lastExternalConflictDetectionKey = fileVersion; + if (writeIntentId) session.lastWriteIntentId = writeIntentId; + if (saveOperationId) session.lastSaveOperationId = saveOperationId; + if (session.latestAggregate && typeof session.latestAggregate === 'object') { + const nextAggregate = { ...session.latestAggregate }; + const nextBody = nextAggregate.body && typeof nextAggregate.body === 'object' + ? { ...nextAggregate.body } + : {}; + nextBody.fileVersion = fileVersion; + nextBody.conflictDetectionKey = fileVersion; + nextBody.conflict_detection_key = fileVersion; + nextAggregate.body = nextBody; + session.latestAggregate = nextAggregate; + syncPageAggregateScript(session, nextAggregate); + } + session.externalChangePending = false; + session.hasExternalConflict = false; + session.lastExternalConflictEnvelope = null; + session.bufferDirtyState = 'Clean'; + if (!serialized || serialized === session.currentSerialized) { + session.lastPersistedSerialized = session.currentSerialized; + session.dirty = false; + setSessionStatus(session, 'saved'); + } else { + session.dirty = true; + setSessionStatus(session, 'dirty'); + queueSessionSave(session); + } + syncResourceSessionTabGuards(session); + applied += 1; + }); + return applied; + }; + window.addEventListener('tree:delta', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange); window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); }); + window.addEventListener('mnote:local-upload-editor-save-completed', (event) => { + applyLocalUploadEditorSave(event?.detail || {}); + }); const createDocumentSession = (runtimeDescriptor) => { const pageBody = runtimeDescriptor.aggregate.body || {}; @@ -1291,6 +1609,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const keyRevision = revisionFromConflictKey(conflictDetectionKey); const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap); + const pageBodySource = pageBodyTiptapDocumentSource(pageBody, ''); const session = { key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), documentId: runtimeDescriptor.bootstrap.documentId, @@ -1301,6 +1620,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__', latestAggregate: runtimeDescriptor.aggregate, title: runtimeDescriptor.aggregate.head?.title || '无标题', + pageBodySource, + projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(), + blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(), currentTiptapDocument: tiptapDocument, currentSerialized: JSON.stringify(tiptapDocument), lastPersistedSerialized: JSON.stringify(tiptapDocument), @@ -1308,6 +1630,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { conflictDetectionKey, fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey, lastExternalConflictDetectionKey: conflictDetectionKey || '', + relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId), + bufferDirtyState: 'Clean', readOnly: Boolean(permissions.readOnly), dirty: false, saving: false, @@ -1355,6 +1679,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { markSessionExternalConflict, queueSessionSave, refreshSessionFromExternalChange, + ensureLocalFolderEventChannel, releaseDocumentSession, scheduleDocumentSessionRelease, sessionHasRecentExternalSignal, diff --git a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js index 7dc20f9d..a13b6dc5 100644 --- a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -394,8 +394,18 @@ export const localizeTiptapAssetUrls = (node, context) => { return node; }; -export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { +export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => { if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) { + return 'local_markdown.content'; + } + const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; + if (blockDocument) return 'page_aggregate.block_document'; + if (body?.content) return 'compat.legacy_content'; + return fallbackText ? 'degraded.fallback_text' : 'empty'; +}; + +export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { + if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') { return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context); } const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; diff --git a/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js b/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js index 4721cd03..91076e19 100644 --- a/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-context-menu-runtime.js @@ -1,7 +1,122 @@ // MNote 文件树上下文菜单运行时外置模块。 // 当前先承接 CommandContext 构造与 when 表达式求值,树语义仍由 Rust/kernel 主导。 +function commandContextResourceKindForRow(row) { + if (!(row instanceof HTMLElement)) return 'unknown'; + var rowKind = String(row.getAttribute('data-row-kind') || '').trim(); + var assetType = String(row.getAttribute('data-asset-type') || '').trim(); + var assetId = String(row.getAttribute('data-asset-id') || '').trim(); + if (rowKind === 'folder' || rowKind === 'directory' || rowKind === 'asset-folder') return 'folder'; + if (rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') return 'page'; + if (assetType) return assetType; + if (assetId) return 'asset'; + return rowKind || 'unknown'; +} + +function commandContextDirtyState(value) { + var state = String(value || '').trim(); + if (!state) return 'clean'; + var lowered = state.toLowerCase(); + if (lowered === 'dirty' || lowered === 'stale' || lowered === 'deleted') return lowered; + if (lowered === 'externalmodified' || lowered === 'external_modified' || lowered === 'external-change-conflict') return 'external_modified'; + if (lowered === 'hasexternalconflict' || lowered === 'has_external_conflict') return 'has_external_conflict'; + return lowered === 'clean' || lowered === 'saved' ? 'clean' : lowered; +} + +function commandContextIsDirtyState(value) { + return commandContextDirtyState(value) !== 'clean'; +} + +function commandContextEditorMatchesRow(editor, row) { + if (!editor || !(row instanceof HTMLElement)) return false; + var rowDocumentId = String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim(); + if (rowDocumentId && String(editor.documentId || '').trim() === rowDocumentId) return true; + var rowAssetId = String(row.getAttribute('data-asset-id') || '').trim(); + if (rowAssetId && String(editor.assetId || editor.workspacePath && editor.workspacePath.assetId || '').trim() === rowAssetId) return true; + var rowRelativePath = String(row.getAttribute('data-local-relative-path') || '').trim(); + var editorRelativePath = String(editor.workspacePath && (editor.workspacePath.relativePath || editor.workspacePath.localRelativePath) || '').trim(); + return Boolean(rowRelativePath && editorRelativePath && rowRelativePath === editorRelativePath); +} + +function commandContextDirtyStateForRows(rows, openEditorsSnapshot, bufferState) { + if (bufferState && typeof bufferState === 'object') { + var directState = bufferState.dirtyState || bufferState.state || bufferState.status || ''; + if (commandContextIsDirtyState(directState)) return commandContextDirtyState(directState); + } + var editors = []; + if (openEditorsSnapshot && Array.isArray(openEditorsSnapshot.editors)) editors = editors.concat(openEditorsSnapshot.editors); + if (openEditorsSnapshot && Array.isArray(openEditorsSnapshot.resourceEditors)) editors = editors.concat(openEditorsSnapshot.resourceEditors); + for (var r = 0; r < rows.length; r += 1) { + var row = rows[r]; + for (var i = 0; i < editors.length; i += 1) { + var editor = editors[i]; + if (!commandContextEditorMatchesRow(editor, row)) continue; + var editorState = editor.dirtyState || editor.dirtyGuard || ''; + if (commandContextIsDirtyState(editorState)) return commandContextDirtyState(editorState); + } + } + return 'clean'; +} + +function selectedRowsFromDeps(deps, fallbackRow) { + var selection = deps && deps.selection ? deps.selection : {}; + var rowIds = Array.isArray(selection.selectedRowIds) + ? selection.selectedRowIds + : Array.from(selection.selectedRowIds || []); + var rows = []; + var queryRowById = deps && typeof deps.queryRowById === 'function' + ? deps.queryRowById + : function() { return null; }; + for (var i = 0; i < rowIds.length; i += 1) { + var row = queryRowById(rowIds[i]); + if (row instanceof HTMLElement) rows.push(row); + } + if (!rows.length && fallbackRow instanceof HTMLElement) rows.push(fallbackRow); + return rows; +} + +function buildFileTreeCommandContext(row, openEditorsSnapshot, bufferState, deps) { + deps = deps || {}; + var rows = selectedRowsFromDeps(deps, row); + var resourceKinds = {}; + for (var j = 0; j < rows.length; j += 1) { + resourceKinds[commandContextResourceKindForRow(rows[j])] = true; + } + var rkKeys = Object.keys(resourceKinds); + var currentSourceKind = typeof deps.currentSourceKind === 'function' + ? deps.currentSourceKind + : function() { return ''; }; + var readonly = typeof deps.workspaceReadonly === 'function' + ? deps.workspaceReadonly() + : false; + var dirtyState = commandContextDirtyStateForRows(rows, openEditorsSnapshot, bufferState); + var editorDirty = commandContextIsDirtyState(dirtyState); + var targetKind = commandContextResourceKindForRow(row); + return { + 'workspace.sourceKind': currentSourceKind() || '', + 'workspace.readonly': readonly === true, + 'tree.focusKind': deps.focusKind || 'file_tree', + 'tree.selectionCount': rows.length, + 'tree.selectionResourceKind': rkKeys.length === 1 ? rkKeys[0] : rows.length ? 'mixed' : targetKind, + 'tree.targetResourceKind': targetKind, + 'tree.targetRowKind': row instanceof HTMLElement ? String(row.getAttribute('data-row-kind') || '').trim() : '', + 'tree.targetIsFolder': targetKind === 'folder', + 'tree.targetIsAsset': Boolean(row instanceof HTMLElement && String(row.getAttribute('data-asset-id') || '').trim()), + 'editor.dirty': editorDirty, + 'editor.dirtyState': dirtyState, + 'editor.hasSelection': Boolean(deps.editorHasSelection), + 'ai.canWrite': readonly !== true && !editorDirty, + }; +} + function buildSidebarFileTreeContext(kind, deps) { + var targetRow = deps && deps.targetRow instanceof HTMLElement ? deps.targetRow : null; + return buildFileTreeCommandContext(targetRow, deps && deps.openEditorsSnapshot, deps && deps.bufferState, Object.assign({}, deps || {}, { + focusKind: kind === 'filetree' ? 'file_tree' : kind === 'page' ? 'page_tree' : kind, + })); +} + +function buildLegacySidebarFileTreeContext(kind, deps) { var selection = deps && deps.selection ? deps.selection : {}; var rowIds = Array.isArray(selection.selectedRowIds) ? selection.selectedRowIds @@ -181,7 +296,9 @@ function tokenizeWhenExpression(expr) { } window.__mnoteFileTreeContextMenuRuntime = { + buildFileTreeCommandContext: buildFileTreeCommandContext, buildSidebarFileTreeContext: buildSidebarFileTreeContext, + buildLegacySidebarFileTreeContext: buildLegacySidebarFileTreeContext, evaluateSidebarFileTreeWhen: evaluateSidebarFileTreeWhen, tokenizeWhenExpression: tokenizeWhenExpression }; diff --git a/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js b/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js index cbe62329..ba08301e 100644 --- a/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-keyboard-runtime.js @@ -42,6 +42,10 @@ function handleFileTreeKeyDown(event, deps) { if (event.key === 'F2') { event.preventDefault(); + var renameCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey }); + if (!evaluateSidebarFileTreeWhen(renameCtx, '!workspace.readonly && !editor.dirty')) { + return true; + } beginFileTreeInlineRename(fileTreeRowForKey); return true; } @@ -61,6 +65,10 @@ function handleFileTreeKeyDown(event, deps) { } if (shortcutKey === 'v') { event.preventDefault(); + var pasteCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey }); + if (!evaluateSidebarFileTreeWhen(pasteCtx, '!workspace.readonly')) { + return true; + } if (typeof deps.pasteSidebarFileTreeClipboard === 'function') { void deps.pasteSidebarFileTreeClipboard(fileTreeRowForKey); } @@ -70,8 +78,8 @@ function handleFileTreeKeyDown(event, deps) { if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); - var delCtx = buildSidebarFileTreeContext('filetree'); - if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly')) { + var delCtx = buildSidebarFileTreeContext('filetree', { targetRow: fileTreeRowForKey }); + if (!evaluateSidebarFileTreeWhen(delCtx, '!workspace.readonly && !editor.dirty')) { return true; } void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey); diff --git a/rust/crates/mnote-web/browser/filetree-runtime.js b/rust/crates/mnote-web/browser/filetree-runtime.js index 59f01f6e..97b229b8 100644 --- a/rust/crates/mnote-web/browser/filetree-runtime.js +++ b/rust/crates/mnote-web/browser/filetree-runtime.js @@ -70,6 +70,57 @@ function fileTreeRowLocalUploadTargetRelativePath(row, deps) { return lastSlash >= 0 ? relativePath.slice(0, lastSlash) : ''; } +function parseObjectIdentityAttr(value) { + var raw = String(value || '').trim(); + if (!raw) return null; + try { + var parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : { raw: raw }; + } catch (_) { + return { raw: raw }; + } +} + +function readWorkspacePathFromRow(row, deps) { + if (!(row instanceof HTMLElement)) return null; + deps = deps || {}; + var currentSourceKind = typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; }; + var currentRootUri = typeof deps.currentRootUri === 'function' ? deps.currentRootUri : function() { return ''; }; + var resolveWorkspaceId = typeof deps.resolveWorkspaceId === 'function' ? deps.resolveWorkspaceId : function() { return ''; }; + var rowTitle = typeof deps.rowTitle === 'function' ? deps.rowTitle : function(target) { + return String(target && target.textContent || '').trim(); + }; + var objectIdentityRaw = String(row.getAttribute('data-object-identity') || '').trim(); + var objectIdentity = parseObjectIdentityAttr(objectIdentityRaw); + var objectKind = String(row.getAttribute('data-object-kind') || (objectIdentity && objectIdentity.objectKind) || '').trim(); + var relativePath = fileTreeRowLocalRelativePath(row, deps); + var assetId = fileTreeRowAssetId(row, deps); + var documentId = fileTreeRowDocumentId(row); + var rowId = String(row.getAttribute('data-row-id') || '').trim(); + var rowKind = fileTreeRowKind(row); + var sourceKind = String(row.getAttribute('data-source-kind') || currentSourceKind() || '').trim(); + var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim(); + return { + schema: 'mnote.workspace_path.v1', + workspaceId: String(row.getAttribute('data-workspace-id') || resolveWorkspaceId(row) || '').trim(), + sourceKind: sourceKind, + rootUri: rootUri, + relativePath: relativePath, + localRelativePath: relativePath, + objectIdentity: objectIdentity, + objectIdentityRaw: objectIdentityRaw, + objectKind: objectKind, + resourceKind: objectKind || rowKind, + rowId: rowId, + rowKind: rowKind, + documentId: documentId, + assetId: assetId, + title: rowTitle(row), + href: String(row.querySelector?.('.tree-link')?.getAttribute?.('href') || '').trim(), + isLocalFolder: sourceKind === 'local_folder' || (!sourceKind && (rowId.indexOf('local:') === 0 || documentId.indexOf('local-md:') === 0 || documentId.indexOf('local-dir:') === 0)) + }; +} + function isFileTreeDownloadableAssetRow(row, deps) { if (!(row instanceof HTMLElement)) return false; var kind = fileTreeRowKind(row); @@ -98,6 +149,7 @@ function fileTreeAssetDownloadDetail(row, deps) { rowKind: fileTreeRowKind(row), assetId: assetId, localRelativePath: relativePath, + workspacePath: readWorkspacePathFromRow(row, deps), title: title, fileName: title, workspaceId: resolveWorkspaceId(row) @@ -274,6 +326,49 @@ function revealFileTreeAssetRow(assetId, deps) { return true; } +function localUploadedAssetRelativePath(asset, deps) { + deps = deps || {}; + var direct = String(asset && (asset.rootRelativePath || asset.root_relative_path || asset.relativePath || asset.relative_path) || '').trim(); + if (direct) return direct; + var localFilePathFromAssetId = typeof deps.localFilePathFromAssetId === 'function' + ? deps.localFilePathFromAssetId + : function(assetId) { + var value = String(assetId || '').trim(); + return value.indexOf('local-file:') === 0 + ? value.slice('local-file:'.length) + : value.indexOf('local:asset:') === 0 + ? value.slice('local:asset:'.length) + : ''; + }; + return localFilePathFromAssetId(String(asset && asset.id || '').trim()); +} + +function parentRelativePathForUploadedAsset(asset, relativePath) { + var target = String(asset && (asset.targetRelativePath || asset.target_relative_path) || '').trim(); + if (target || Object.prototype.hasOwnProperty.call(asset || {}, 'targetRelativePath')) return target; + var normalized = String(relativePath || '').trim().replace(/^\/+|\/+$/g, ''); + var lastSlash = normalized.lastIndexOf('/'); + return lastSlash >= 0 ? normalized.slice(0, lastSlash) : ''; +} + +function findLocalFileTreeParentRowForAsset(asset, relativePath, deps) { + deps = deps || {}; + var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); }; + var currentSourceKind = typeof deps.currentSourceKind === 'function' ? deps.currentSourceKind : function() { return ''; }; + if (currentSourceKind() !== 'local_folder') return null; + var parentRelativePath = parentRelativePathForUploadedAsset(asset, relativePath); + var selector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="' + cssEscape(parentRelativePath) + '"]'; + if (parentRelativePath) { + var parentRow = document.querySelector(selector); + if (parentRow instanceof HTMLElement) return parentRow; + } + if (!parentRelativePath) { + var root = document.querySelector('#sidebar-file-tree-root .tree-root'); + return root instanceof HTMLElement ? null : null; + } + return null; +} + function appendUploadedAssetRow(asset, documentId, deps) { deps = deps || {}; var cssEscape = typeof deps.cssEscape === 'function' ? deps.cssEscape : function(value) { return String(value).replace(/["\\]/g, '\\$&'); }; @@ -295,8 +390,10 @@ function appendUploadedAssetRow(asset, documentId, deps) { if (!objectKind && (String(asset && (asset.asset_type || asset.assetType) || '').trim() === 'mindmap' || /\.mindmap\.json$/i.test(assetId))) { objectKind = 'mindmap'; } - var targetDocumentId = String(documentId || asset.document_id || asset.documentId || currentDocumentId() || '').trim(); - var parentRow = targetDocumentId + var localRelativePath = localUploadedAssetRelativePath(asset, deps); + var targetDocumentId = String(documentId || asset.document_id || asset.documentId || asset.ownerDocumentId || currentDocumentId() || '').trim(); + var parentRow = findLocalFileTreeParentRowForAsset(asset, localRelativePath, deps); + if (!parentRow) parentRow = targetDocumentId ? document.querySelector('.tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + targetDocumentId) + '"]') : null; if (!parentRow && currentSourceKind() === 'local_folder' && objectKind === 'mindmap') return false; @@ -325,16 +422,20 @@ function appendUploadedAssetRow(asset, documentId, deps) { objectKind: objectKind || 'attachment', documentId: targetDocumentId || null, blockId: null, - assetId: assetId + assetId: assetId, + rootUri: String(asset && (asset.rootUri || asset.root_uri) || '').trim() || null, + relativePath: localRelativePath || null }; li.setAttribute('data-node-id', 'asset:' + assetId); var title = uploadedAssetTitle(asset); var iconKind = fileTreeIconKindForFileName(title) || uploadedAssetType(asset) || 'file'; if (objectKind === 'mindmap') iconKind = 'mindmap'; + var ownerDocumentId = String(asset && (asset.ownerDocumentId || asset.owner_document_id) || targetDocumentId || '').trim(); + var rootUri = String(asset && (asset.rootUri || asset.root_uri) || '').trim(); li.innerHTML = - '' + '' + + '' + '' + '
' + '
' + @@ -2146,7 +2734,7 @@ export function createSidebarPageAiRuntime(context) { '
' + '' + '' + '
' + @@ -2154,7 +2742,10 @@ export function createSidebarPageAiRuntime(context) { '
' + '