diff --git a/AGENTS.md b/AGENTS.md index 1cda0350..9f774e28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ - 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。 - 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。 - `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。 +- 浏览器主链默认禁止新增基于 `setInterval`、周期 `setTimeout` 或轮询 fallback 的数据刷新、状态同步和资源存在性检查,尤其是页面树、文件树、文档页、附件、AI 面板等高频可见路径。优先使用 Rust realtime event stream、WebSocket/SSE、watcher、`tree:delta/resync`、`page-aggregate-synced`、`MutationObserver` 和显式 command result 驱动刷新;只有在明确标注的 legacy/debug/internal 边界下,才允许短期保留轮询,并且必须在设计/bug 说明里记录原因、退出条件和内存/CPU 风险。 - 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。 - 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。 - 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` Hermes tools 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。 @@ -118,6 +119,7 @@ - 不要擅自恢复、覆盖、删除用户已有改动。 - 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理。 - 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。 +- 若发现现有轮询或定时刷新链路,不要直接在其上继续叠加补丁;先确认是否能改成事件驱动、watcher/realtime 推送或命令结果定点刷新,再决定是否保留临时 fallback。 - Reasonix / subagent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex/Hermes 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳。 ## CodeGraph 使用 diff --git a/design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md b/design/05-editor-mainline/done/5-34-local-markdown-attachment-ref-contract-v1.md similarity index 60% rename from design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md rename to design/05-editor-mainline/done/5-34-local-markdown-attachment-ref-contract-v1.md index 029b0bd4..66fd42a3 100644 --- a/design/05-editor-mainline/process/5-34-local-markdown-attachment-ref-contract-v1.md +++ b/design/05-editor-mainline/done/5-34-local-markdown-attachment-ref-contract-v1.md @@ -9,8 +9,8 @@ ## 1. 目标 - 把图片、PDF、Office、音视频、普通文件统一收口到标准 Markdown link/image 表达。 -- 上传时默认把文件复制到当前 `.md` 邻接资源目录,并在正文插入相对链接。 -- 用户手写或粘贴 `file://`、`https://`、相对路径、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`。 +- 上传时默认把文件复制到当前 `.md` 所在同目录,并在正文插入相对链接。 +- 用户手写或粘贴同目录相对路径、`file://`、`https://`、兼容裸绝对路径时,内核都能解析为同一类 `AttachmentRef`。 - Tiptap 只负责编辑器节点渲染,不持有资源真相;点击打开交给 resource opener。 - 文件树、resource tab、preview viewer、系统打开、授权检查都基于 `AttachmentRef` / `OpenTarget`,不从 DOM 字符串重复猜测。 @@ -28,34 +28,39 @@ ```md [同目录 PDF](./report.pdf) -[页面附件](./dha_computational_modeling_literature.assets/model.pptx) -![图片](./dha_computational_modeling_literature.assets/figure.png) +[页面附件](./model.pptx) +![图片](./figure.png) [外部本地文件](file:///mnt/Data1T/research/shared/reference.pdf) [网页资料](https://example.com/paper.pdf) ``` -兼容输入: +支持输入: - `./a.pdf`:相对当前 `.md` 所在目录解析。 -- `../assets/a.pdf`:相对当前 `.md` 所在目录解析。 - `file:///mnt/Data1T/a.pdf`:按标准 file URI 解析,必须经过授权 root 检查。 -- `/mnt/Data1T/a.pdf`:作为兼容输入接受;保存时建议规范化为 `file:///mnt/Data1T/a.pdf`,或在同 root 内转换为相对路径。 +- `/mnt/Data1T/a.pdf`:作为开发期兼容输入接受并解析为外部本地文件;本清单不要求保存时自动规范化为 `file://`,也不要求转换为相对路径。 - `https://...` / `http://...`:远程 URL,不进入本地文件授权,但进入 URL 安全策略。 +明确不做: + +- 当前仍处开发态,不做历史 Markdown 中旧 `/office-preview?...` href 的批量迁移、读取兼容迁移或保存时自动重写。 +- 不兼容 `../assets/a.pdf` 这类上级目录相对路径;不解析、不生成、不迁移这类引用。跨目录本地附件请使用经过授权的 `file://` 或裸绝对路径输入。 + 默认上传插入规则: ```text 当前文档:/mnt/Data1T/research/DHA/literature/dha.md 上传文件:/home/lix/Downloads/model.pptx -复制目标:/mnt/Data1T/research/DHA/literature/dha.assets/model-.pptx -正文插入:[model.pptx](./dha.assets/model-.pptx) +复制目标:/mnt/Data1T/research/DHA/literature/model-.pptx +正文插入:[model.pptx](./model-.pptx) ``` 说明: - 相对链接不是不唯一;内核解析时用当前 `.md` 路径补全,得到唯一绝对路径。 - 正文优先保存相对路径,是为了移动文件夹、同步、分享、VSCode/Sidex/Markdown 工具打开时保持可用。 -- 绝对路径作为外部引用保留,但默认不作为上传后的持久格式。 +- 绝对路径作为外部引用保留;上传后的持久格式仍固定为当前 `.md` 同目录相对链接。 +- 默认上传目录维持当前 MNote 行为:与当前 `.md` 同目录。`.assets/`、`assets/` 或用户自定义资源目录只作为未来可选整理策略,不作为本清单默认行为。 ## 4. `AttachmentRef` 合同 @@ -120,9 +125,9 @@ type OpenTarget = { 1. 用户在 Tiptap/Markdown 位置发起上传。 2. 浏览器只持有临时 `UploadDraft`:文件名、进度、取消、错误。 -3. Rust upload API 根据当前 `.md` 路径计算 page-local assets 目录。 +3. Rust upload API 根据当前 `.md` 路径计算同目录上传目标。 4. 后端写入文件,进行文件名净化、冲突命名、可选 hash 去重。 -5. API 返回稳定 Markdown href,如 `./dha.assets/model-.pptx`。 +5. API 返回稳定 Markdown href,如 `./model-.pptx`。 6. Tiptap 将临时上传节点替换为标准 link/image 节点。 7. 保存 Markdown 后,watcher / projection 重新解析出 `AttachmentRef`。 @@ -351,75 +356,99 @@ MNote 借鉴点:上传结果返回稳定 href;文件名净化、冲突命名 ### Batch A:现状冻结与 RED 验证 -- [ ] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。 -- [ ] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。 -- [ ] 截图记录当前卡片、文件树、resource tab、preview 行为。 +- [x] 复现并记录当前附件行为:第一个上传可打开、第二个上传后重渲染仍可打开、文件树显示附件。 +- [x] 新增或扩展浏览器 smoke,断言 Markdown 中不能写入 `/office-preview` 作为持久 href。 +- [x] 截图记录当前卡片、文件树、resource tab、preview 行为。 ### Batch B:Markdown href 解析合同 -- [ ] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。 -- [ ] 支持解析 Markdown image/link 的 `rawHref`、label、source range。 -- [ ] 支持相对路径、`file://`、`http(s)`、兼容裸绝对路径。 -- [ ] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。 +- [x] 在 Rust 层新增 `AttachmentRef` 或等价协议类型。 +- [x] 支持解析 Markdown image/link 的 `rawHref`、label、source range。 +- [x] 支持同目录相对路径、`file://`、`http(s)`、兼容裸绝对路径;不兼容 `../assets/a.pdf`。 +- [x] Rust 单测覆盖路径空格、中文、百分号编码、目录链接、缺失文件。 ### Batch C:授权与 root 解析 -- [ ] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。 -- [ ] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。 -- [ ] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`。 -- [ ] Rust 定点测试覆盖已授权、未授权、跨用户隔离。 +- [x] `file://` 和裸绝对路径必须经过 SQLite control-plane allowed roots 检查。 +- [x] 相对链接解析时必须基于 owner `.md` 路径和当前 rootUri。 +- [x] 不同用户授权隔离,未授权路径 projection 标记 `authorized=false`。 +- [x] Rust 定点测试覆盖已授权、未授权、跨用户隔离。 ### Batch D:上传写入与 href 返回 -- [ ] local upload API 接收 owner document path,不再只返回 preview URL。 -- [ ] 默认写入 `.assets/`。 -- [ ] 文件名净化、冲突命名、可选 hash 去重。 -- [ ] 返回 `{ displayName, markdownHref, attachmentRef }`。 -- [ ] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。 +- [x] local upload API 接收 owner document path,不再只返回 preview URL。 +- [x] 默认写入当前 `.md` 所在同目录。 +- [x] 文件名净化、冲突命名、可选 hash 去重。 +- [x] 返回 `{ displayName, markdownHref, attachmentRef }`。 +- [x] 上传失败不能留下 orphan 正文链接;成功后 watcher 能刷新文件树。 ### Batch E:Tiptap / Markdown 编辑器渲染 -- [ ] 上传中使用临时 `UploadDraft` / upload NodeView。 -- [ ] 成功后替换为标准 link/image,不保存 preview URL。 -- [ ] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。 -- [ ] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。 -- [ ] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。 +- [x] 上传中使用临时 `UploadDraft` / upload NodeView。 +- [x] 成功后替换为标准 link/image,不保存 preview URL。 +- [x] 根据 `AttachmentRef.openKind` 渲染图片、PDF、Office、音视频、普通文件卡片。 +- [x] 卡片 UI 只消费 projection;不把 DOM data 属性当持久真相。 +- [x] 浏览器截图验证卡片形态、hover/click、保存重开后行为一致。 ### Batch F:Resource opener 与 preview -- [ ] 新增或收口 `openAttachment(ref)`。 -- [ ] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。 -- [ ] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。 -- [ ] 缺失和未授权路径显示明确阻断状态。 +- [x] 新增或收口 `openAttachment(ref)`。 +- [x] PDF/Office/image/text/download/system external 分发到 `OpenTarget`。 +- [x] resource tab watch 与附件 ref 绑定,避免第二个附件打开后第一个失效。 +- [x] 缺失和未授权路径显示明确阻断状态。 ### Batch G:File Tree / watcher / missing asset -- [ ] 文件树从附件 projection 显示 page-local assets 目录和文件。 -- [ ] 上传后 watcher 刷新文件树,不依赖手动刷新。 -- [ ] 删除/移动附件后正文卡片显示 missing,不崩溃。 -- [ ] 外部新增同目录附件后,手写相对链接可解析并打开。 +- [x] 文件树从附件 projection 显示当前 `.md` 同目录上传的附件文件。 +- [x] 上传后 watcher 刷新文件树,不依赖手动刷新。 +- [x] 删除/移动附件后正文卡片显示 missing,不崩溃。 +- [x] 外部新增同目录附件后,手写相对链接可解析并打开。 -### Batch H:兼容迁移 +### Batch H:开发态清理与非兼容边界(非迁移) -- [ ] 兼容旧 Markdown 中已有 `/office-preview?...path=...` href,读取时转换为 `AttachmentRef`,保存时迁移为标准 href。 -- [ ] 兼容现有 `data-mnote-attachment-link` DOM 增强路径,但不作为主合同。 -- [ ] 补设计说明:旧路径只做读兼容,不再写入。 +- [x] 确认新写入 Markdown 不再产生 `/office-preview?...`、`/api/local-folder/files/open` 或 DOM 临时属性依赖。 +- [x] 开发态不做旧 `/office-preview?...` href 历史迁移,也不做读取旧 href 后自动重写保存的兼容逻辑;发现旧数据时按当前开发数据重建或手工修正处理。 +- [x] 不兼容 `../assets/a.pdf` 上级目录相对路径;不解析、不生成、不迁移这类引用,跨目录引用走授权 `file://` / 绝对路径。 +- [x] 补设计说明:旧路径和 `data-mnote-attachment-link` 只作为开发期遗留现象排查,不作为本清单验收项。 ### Batch I:验证矩阵 -- [ ] Rust:AttachmentRef parse/resolve/auth/open target 单测。 -- [ ] JS:Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。 -- [ ] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。 -- [ ] Browser:VSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。 -- [ ] Browser:未授权 `file://` 阻断、授权后可打开。 -- [ ] `git diff --check`。 -- [ ] 涉及代码图修改后运行 `codegraph sync .`。 +- [x] Rust:AttachmentRef parse/resolve/auth/open target 单测。 +- [x] JS:Tiptap upload replacement、card renderer、resource opener `node --check` 与定点 smoke。 +- [x] Browser:真实上传 pptx/pdf/docx/png 两个以上文件,保存重开后逐个点击可预览。 +- [x] Browser:VSCode/Sidex 兼容表达验证,Markdown 原文为标准 link/image。 +- [x] Browser:未授权 `file://` 阻断、授权后可打开。 +- [x] `git diff --check`。 +- [x] 涉及代码图修改后运行 `codegraph sync .`。 -## 10. 验收标准 +## 10. 执行证据(2026-05-28) + +- Rust:`cargo test -p mnote-web markdown_attachment_refs --manifest-path rust/Cargo.toml` 通过,覆盖标准 link/image、`file://`、http(s)、裸绝对路径、中文/空格/百分号、缺失文件、`../assets` unknown。 +- Rust:`cargo test -p mnote-web local_markdown_asset_upload_copies_next_to_markdown_with_relative_path --manifest-path rust/Cargo.toml` 通过,确认上传写入当前 `.md` 同目录。 +- Rust:`cargo test -p mnote-web local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants --manifest-path rust/Cargo.toml` 通过,覆盖 SQLite allowed roots 与跨用户隔离。 +- Rust:`cargo test -p mnote-web local_markdown_save_does_not_migrate_runtime_open_url_inline_link --manifest-path rust/Cargo.toml`、`cargo test -p mnote-web local_markdown_save_does_not_migrate_relative_runtime_open_url --manifest-path rust/Cargo.toml` 通过,确认开发态不做旧 runtime URL 自动迁移。 +- JS:`node --check` 通过 `sidebar-attachment-open-runtime.js`、`local-upload-runtime.js`、`sidebar-tree-runtime.js`、`document-tiptap-conversion-runtime.js`、`document-editor-adapter-runtime.js`、`task506-local-markdown-attachment-ref-matrix-smoke.js`。 +- Browser:`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task503-local-pptx-upload-filetree-open-smoke.js` 通过,证据:`tmp/task503-local-pptx-upload-filetree-open-smoke/result.json`。 +- Browser:`MNOTE_SMOKE_UI_TIMEOUT_MS=60000 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,证据:`tmp/task506-local-markdown-attachment-ref-matrix-smoke/result.json`;覆盖 PDF/PNG/DOCX 上传、授权外部 file、未授权 file 阻断、`../assets` unknown、缺失附件阻断、Markdown 原文标准 link/image。 +- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。 + +剩余风险:missing/unauthorized 的可见样式以 Tiptap link mark 中的稳定 class 为主;`data-mnote-attachment-*` 只作为 runtime 补偿属性,不作为持久合同。已用 `task506` 验证无需显式 flush 的自动 missing 显示、点击阻断和截图证据。附件增强链不得引入周期刷新;当前已移除 `setInterval` 与启动延迟轮询,只保留 MutationObserver、page aggregate synced、tree delta/resync 等事件驱动入口。 + +## 10.1 回归修复证据(2026-05-29) + +- Root cause:中文 bundle 页面刷新前的附件 fallback 曾只把 `local-md:` 中的 `~2F` 解为 `/`,未完整解码 `~E6...` UTF-8 字节,导致 `./a.pdf` 被拼成 `~E6.../a.pdf` 并请求 `/api/local-folder/files/open` 返回 400。已改为 `~XX -> %XX -> decodeURIComponent`。 +- Root cause:新建页面和点击 `.md` 行时,filetree 选择逻辑会先命中带同一 `documentId` 的 bundle folder,导致 selected/focused/active 落父文件夹。已改为优先消费 `selectTarget.rowId/relativePath` 并通过 `revealFileTreeResource` 展开、聚焦真实 Markdown 行,只有 reveal 失败才退回父文件夹。 +- Browser:`node scripts/task494-filetree-lazy-loading-dedup-smoke.js` 通过,覆盖新建页面后 bundle 展开,内部 `.md` 行 selected/focused/active。 +- Browser:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:45231 node scripts/task506-local-markdown-attachment-ref-matrix-smoke.js` 通过,覆盖中文页面下 PDF 上传后不刷新立即点击打开、文件树显示同目录附件、授权/未授权 file、missing 阻断;截图 `tmp/task506-local-markdown-attachment-ref-matrix-smoke/00-uploaded-pdf-open-before-reload.png`。 +- JS:`node --check` 通过 `sidebar-attachment-open-runtime.js`、`sidebar-filetree-command-runtime.js`、`sidebar-page-tree-runtime.js`、`task494-filetree-lazy-loading-dedup-smoke.js`、`task506-local-markdown-attachment-ref-matrix-smoke.js`。 +- Rust:`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_tree_command_create_page_creates_timestamped_nested_bundle -- --nocapture` 通过。 +- 收尾:`git diff --check` 通过;`codegraph sync .` 通过。`codegraph status .` 仍显示 `Pending Changes: Added: 1 files`,属于当前工作区 5-34 文件移动/未跟踪状态遗留,非本轮 runtime 修复失败。 + +## 11. 验收标准 - 任意时刻上传附件,正文只产生标准 Markdown href,不产生运行时 preview URL。 - 第一个、第二个、第三个附件保存重渲染后都能点击并打开。 -- 文件树能显示 page-local 附件,且 watcher 自动刷新。 +- 文件树能显示当前 `.md` 同目录上传的附件,且 watcher 自动刷新。 - 复制整个本地文件夹到新位置后,相对附件仍可解析。 - `file://` 外部引用在授权 root 内可打开,未授权时阻断。 - Tiptap 卡片、resource tab、preview opener 都消费同一 `AttachmentRef` projection。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 5f7fac2d..eb7e370e 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -5978,6 +5978,7 @@ fn build_page_aggregate_projection_result( block_document, block_projection_version, projection_source, + attachment_refs: Value::Array(Vec::new()), }, tree: PageTree { page_subtree }, stats: PageStats { diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index f000fdc6..4011ea6f 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -299,6 +299,7 @@ mod tests { }), block_projection_version: 1, projection_source: "fixture".into(), + attachment_refs: serde_json::json!([]), }, tree: page_aggregate::PageTree { page_subtree: serde_json::json!({"rootNodeId": "page_1"}), diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs index bbb60190..cdacc174 100644 --- a/rust/crates/core-protocol/src/page_aggregate.rs +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -142,6 +142,7 @@ mod tests { assert_eq!(body.block_document, json!(null)); assert_eq!(body.projection_source, ""); assert_eq!(body.file_version, json!(null)); + assert_eq!(body.attachment_refs, json!(null)); } } @@ -159,6 +160,8 @@ pub struct PageBody { pub block_projection_version: u32, #[serde(default)] pub projection_source: String, + #[serde(default)] + pub attachment_refs: Value, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 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 dc8fdd65..fe6b8ef3 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -36,6 +36,19 @@ import { const ERROR_EVENT = `${EVENT_PREFIX}:error`; const COMMAND_EVENT = `${EVENT_PREFIX}:command`; const BRIDGE_PROTOCOL = 'mnote.leptos_tiptap.bridge.v1'; + const DEV_HOT_BUSTER = (() => { + try { + return new URL(import.meta.url).searchParams.get('devHot') || ''; + } catch (_) { + return ''; + } + })(); + + const withDevHot = (path) => { + const url = new URL(path, window.location.origin); + if (DEV_HOT_BUSTER) url.searchParams.set('devHot', DEV_HOT_BUSTER); + return url.toString(); + }; const parseJsonScript = (id) => { const node = document.getElementById(id); @@ -66,12 +79,12 @@ import { return window.__mnoteLeptosTiptapRuntimePromise; } window.__mnoteLeptosTiptapRuntimePromise = (async () => { - const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json'); + const manifestResponse = await fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json')); if (!manifestResponse.ok) throw new Error(`manifest_failed_${manifestResponse.status}`); const manifest = await manifestResponse.json(); if (!manifest.entryAssetPath) throw new Error('island manifest 缺少 entryAssetPath'); - const entryUrl = `/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`; - const wasmUrl = manifest.wasmAssetPath ? `/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}` : undefined; + const entryUrl = withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`); + const wasmUrl = manifest.wasmAssetPath ? withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`) : undefined; const runtime = await import(entryUrl); if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') { throw new Error('island runtime 导出不完整'); @@ -204,6 +217,7 @@ import { window.dispatchEvent(new CustomEvent('mnote:page-aggregate-synced', { detail: { scriptId, documentId: aggregateDocumentId(nextAggregate) } })); + enhanceEditorAttachmentLinksSoon(); } catch (error) { console.warn('mnote Page Aggregate script 同步失败', error); } 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 a13b6dc5..20abd4a5 100644 --- a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -363,27 +363,77 @@ export const mergeClassNames = (...values) => { export const localAttachmentClassForTiptapHref = (href) => { const localFilePath = localFileOpenPathFromTiptapHref(href); - return localFilePath ? attachmentClassForFileName(fileNameFromPath(localFilePath)) : ''; + if (localFilePath) return attachmentClassForFileName(fileNameFromPath(localFilePath)); + const value = String(href || '').trim(); + if (!value || isExternalOrSpecialUrl(value)) return ''; + if (value.startsWith('../') || value.includes('/../')) return ''; + return attachmentClassForFileName(fileNameFromPath(value)); +}; + +const attachmentRefsFromContext = (context) => { + if (Array.isArray(context?.attachmentRefs)) return context.attachmentRefs; + if (Array.isArray(context?.attachment_refs)) return context.attachment_refs; + if (Array.isArray(context?.body?.attachmentRefs)) return context.body.attachmentRefs; + if (Array.isArray(context?.body?.attachment_refs)) return context.body.attachment_refs; + if (Array.isArray(context?.latestAggregate?.body?.attachmentRefs)) return context.latestAggregate.body.attachmentRefs; + if (Array.isArray(context?.latestAggregate?.body?.attachment_refs)) return context.latestAggregate.body.attachment_refs; + if (Array.isArray(context?.aggregate?.body?.attachmentRefs)) return context.aggregate.body.attachmentRefs; + if (Array.isArray(context?.aggregate?.body?.attachment_refs)) return context.aggregate.body.attachment_refs; + return []; +}; + +const attachmentRefForTiptapHref = (href, context) => { + const value = String(href || '').trim(); + if (!value) return null; + return attachmentRefsFromContext(context).find((ref) => ( + ref && typeof ref === 'object' && ( + String(ref.rawHref || '') === value + || String(ref.normalizedHref || '') === value + || String(ref.resolvedUri || '') === value + ) + )) || null; +}; + +const withAttachmentProjectionAttrs = (attrs, attachmentRef) => { + if (!attachmentRef || typeof attachmentRef !== 'object') return attrs; + const next = { ...attrs }; + if (attachmentRef.exists === false) { + next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-missing'); + next['data-mnote-attachment-missing'] = 'true'; + next['aria-label'] = `${String(attachmentRef.label || '附件')}(文件不存在)`; + } + if (attachmentRef.authorized === false) { + next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-unauthorized'); + next['data-mnote-attachment-unauthorized'] = 'true'; + next['aria-label'] = `${String(attachmentRef.label || '附件')}(无权访问)`; + } + return next; }; export const localizeTiptapAssetUrls = (node, context) => { if (!node || typeof node !== 'object') return node; if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { - node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) }; + const originalSrc = String(node.attrs.src || '').trim(); + node.attrs = { + ...node.attrs, + src: localFileOpenUrlForTiptap(originalSrc, context), + mnoteMarkdownSrc: node.attrs.mnoteMarkdownSrc || originalSrc, + }; } if (Array.isArray(node.marks)) { node.marks = node.marks.map((mark) => { if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; - const href = localFileOpenUrlForTiptap(mark.attrs.href, context); + const href = String(mark.attrs.href || '').trim(); const attachmentClass = localAttachmentClassForTiptapHref(href); + const attachmentRef = attachmentRefForTiptapHref(href, context); const attrs = attachmentClass - ? { + ? withAttachmentProjectionAttrs({ ...mark.attrs, href, class: mergeClassNames(mark.attrs.class, attachmentClass), target: mark.attrs.target || '_blank', rel: mark.attrs.rel || 'noopener noreferrer nofollow', - } + }, attachmentRef) : { ...mark.attrs, href }; return { ...mark, attrs }; }); @@ -405,12 +455,13 @@ export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => { }; export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { + const projectionContext = { ...(context || {}), body }; if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') { - return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context); + return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), projectionContext); } const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; - if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context); - return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context); + if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext); + return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), projectionContext); }; export const inlineTextNodes = (node) => { @@ -524,7 +575,7 @@ export const tiptapNodeToEditorBlock = (node, index) => { if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] }; if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] }; - if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.mnoteMarkdownSrc || node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] }; if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] }; if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; return null; diff --git a/rust/crates/mnote-web/browser/local-upload-runtime.js b/rust/crates/mnote-web/browser/local-upload-runtime.js index a16f5043..61ab0e38 100644 --- a/rust/crates/mnote-web/browser/local-upload-runtime.js +++ b/rust/crates/mnote-web/browser/local-upload-runtime.js @@ -15,6 +15,20 @@ function uploadedAssetUrl(asset) { return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); } +function uploadedAssetMarkdownHref(asset) { + var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim(); + if (href) { + var normalizedHref = href.replace(/\\/g, '/'); + if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return ''; + return href; + } + var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/'); + if (!relativePath) return ''; + if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return ''; + if (relativePath.indexOf('./') === 0) return relativePath; + return './' + relativePath; +} + function dispatchUploadedEditorChange(editorRoot, editor, deps) { deps = deps || {}; if (!(editorRoot instanceof HTMLElement) || !editor || typeof editor.getJSON !== 'function') return; @@ -431,37 +445,31 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { return false; } var title = typeof deps.uploadedAssetTitle === 'function' ? deps.uploadedAssetTitle(asset) : uploadedAssetTitle(asset); + var markdownHref = typeof deps.uploadedAssetMarkdownHref === 'function' ? deps.uploadedAssetMarkdownHref(asset) : uploadedAssetMarkdownHref(asset); var localOpenUrl = typeof deps.localAssetOpenUrl === 'function' ? deps.localAssetOpenUrl(asset, false) : localAssetOpenUrl(asset, false); var fallbackUrl = typeof deps.uploadedAssetUrl === 'function' ? deps.uploadedAssetUrl(asset) : uploadedAssetUrl(asset); - var url = localOpenUrl || fallbackUrl; + var url = markdownHref || localOpenUrl || fallbackUrl; var type = typeof deps.uploadedAssetType === 'function' ? deps.uploadedAssetType(asset) : uploadedAssetType(asset); var assetId = String(asset && asset.id || '').trim(); var sizeLabel = typeof deps.uploadedFileSize === 'function' ? deps.uploadedFileSize(asset) : uploadedFileSize(asset); try { if (type === 'image' && url) { - return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true; + var imageInserted = editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true; + if (imageInserted) { + document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'true'); + document.documentElement.setAttribute('data-mnote-last-upload-asset-id', assetId || ''); + document.documentElement.removeAttribute('data-mnote-last-upload-insert-error'); + dispatchUploadedEditorChange(editorRoot, editor, deps); + await persistUploadedEditorChange(editor, asset, deps); + } else { + document.documentElement.setAttribute('data-mnote-last-upload-inserted', 'false'); + document.documentElement.setAttribute('data-mnote-last-upload-insert-error', 'insert_image_failed'); + } + return imageInserted; } var isLocalAsset = typeof deps.isLocalUploadedAsset === 'function' ? deps.isLocalUploadedAsset(asset) : isLocalUploadedAsset(asset); - var userId = ''; - var buildOnlyOfficeAssetOpenUrl = typeof deps.buildOnlyOfficeAssetOpenUrl === 'function' ? deps.buildOnlyOfficeAssetOpenUrl : function() { return ''; }; - var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); - if (onlyOfficeUrl && assetId) { - userId = typeof deps.fetchCurrentOnlyOfficeUserId === 'function' ? await deps.fetchCurrentOnlyOfficeUserId() : ''; - onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); - } - var href = onlyOfficeUrl || url; + var href = markdownHref || url; if (href) { - var storedHref = onlyOfficeUrl - ? (isLocalAsset ? onlyOfficeUrl : deps.buildOnlyOfficeOpenPath({ - fileUrl: '', - fileName: title, - fileType: deps.inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx', - assetId: assetId, - documentId: String(asset && (asset.document_id || asset.documentId) || deps.currentDocumentId() || '').trim(), - userId: userId || '', - mode: 'view' - })) - : href; var inserted = editor.chain().focus().insertContent([ { type: 'paragraph', @@ -471,7 +479,7 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { marks: [{ type: 'link', attrs: { - href: storedHref, + href: href, target: '_blank', rel: 'noopener noreferrer nofollow', class: typeof deps.uploadedAttachmentClass === 'function' ? deps.uploadedAttachmentClass(asset) : uploadedAttachmentClass(asset) @@ -510,7 +518,6 @@ async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) { }) || null; } if (link instanceof HTMLElement) { - link.setAttribute('data-mnote-attachment-link', 'true'); if (assetId) link.setAttribute('data-asset-id', assetId); if (sizeLabel) link.setAttribute('data-file-size', sizeLabel); } else if (Number(attempt) < 20) { @@ -755,6 +762,7 @@ function uploadedFileSize(asset) { window.__mnoteLocalUploadRuntime = { uploadedAssetTitle: uploadedAssetTitle, uploadedAssetUrl: uploadedAssetUrl, + uploadedAssetMarkdownHref: uploadedAssetMarkdownHref, resolveEditorUploadContext: resolveEditorUploadContext, editorRootFromUploadOptions: editorRootFromUploadOptions, openEditorUploadFilePicker: openEditorUploadFilePicker, diff --git a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js index 3d09308a..8526f0fe 100644 --- a/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js @@ -11,7 +11,6 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { currentRootUri, currentWorkspaceSourcePayload, fileTreeIconKindForFileName, - healLegacyOfficeAttachmentParagraphs, hydrateEditorAttachmentMeta: injectedHydrateEditorAttachmentMeta, inferCodeAttachmentLanguage, inferOnlyOfficeFileType, @@ -38,7 +37,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { var attachmentActionsHideTimer = 0; var editorAttachmentMissingByKey = new Map(); var editorAttachmentRefreshSeqByKey = new Map(); - var editorAttachmentLinkSelector = '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]'; + var editorAttachmentLinkSelector = '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row'; + var editorAttachmentEnhanceSelector = '.editor-surface .ProseMirror a[href], ' + editorAttachmentLinkSelector; function attachmentQueryParams(href) { try { @@ -58,6 +58,51 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } } + function decodeLocalEncodedPath(value) { + var path = String(value || '').trim().replace(/~([0-9A-Fa-f]{2})/g, '%$1'); + if (!path) return ''; + try { + return decodeURIComponent(path); + } catch (_) { + return path; + } + } + + function localMarkdownPathFromDocumentId(documentId) { + var raw = String(documentId || '').trim(); + if (raw.indexOf('local-md:') !== 0) return ''; + return decodeLocalEncodedPath(raw.slice('local-md:'.length)); + } + + function normalizeLocalRelativePath(path) { + var parts = String(path || '').replace(/\\/g, '/').split('/'); + var normalized = []; + for (var i = 0; i < parts.length; i += 1) { + var part = parts[i]; + if (!part || part === '.') continue; + if (part === '..') { + if (normalized.length) normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join('/'); + } + + function localMarkdownAttachmentPathFromHref(href, documentId) { + var raw = String(href || '').trim(); + if (!raw || raw.indexOf('#') === 0 || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return ''; + var documentPath = localMarkdownPathFromDocumentId(documentId); + if (!documentPath) return ''; + var slashIndex = documentPath.lastIndexOf('/'); + var documentDir = slashIndex >= 0 ? documentPath.slice(0, slashIndex) : ''; + var decoded = raw; + try { decoded = decodeURIComponent(raw); } catch (_) {} + if (decoded.indexOf('../') === 0 || decoded.indexOf('/../') >= 0) return ''; + var joined = documentDir ? documentDir + '/' + decoded : decoded; + return normalizeLocalRelativePath(joined); + } + function localFileOpenRootUriFromHref(href) { try { var url = new URL(String(href || ''), window.location.origin); @@ -84,6 +129,77 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { return url.toString(); } + function buildLocalFileOpenUrlForRoot(relativePath, rootUri, download) { + var effectiveRootUri = String(rootUri || currentRootUri() || '').trim(); + if (!effectiveRootUri || !relativePath) return ''; + var url = new URL('/api/local-folder/files/open', window.location.origin); + url.searchParams.set('rootUri', effectiveRootUri); + url.searchParams.set('path', relativePath); + if (download) url.searchParams.set('download', 'true'); + return url.toString(); + } + + function fileUriToPath(uri) { + var value = String(uri || '').trim(); + if (value.indexOf('file://') !== 0) return ''; + try { + var url = new URL(value); + return decodeURIComponent(url.pathname || ''); + } catch (_) { + var raw = value.slice('file://'.length); + if (raw.indexOf('localhost/') === 0) raw = raw.slice('localhost'.length); + if (raw.charAt(0) !== '/') raw = '/' + raw; + try { raw = decodeURIComponent(raw); } catch (_) {} + return raw; + } + } + + function fileUriFromPath(path) { + var value = String(path || '').trim(); + if (!value) return ''; + return 'file://' + value.split('/').map(function(part, index) { + return index === 0 ? '' : encodeURIComponent(part); + }).join('/'); + } + + function localAttachmentOpenParts(rawHref, attachmentRef, paneContext) { + if (attachmentRef && typeof attachmentRef === 'object') { + var refRelativePath = String(attachmentRef.relativePath || '').trim(); + if (refRelativePath) { + return { + rootUri: String(attachmentRef.ownerRootUri || currentRootUri() || '').trim(), + relativePath: refRelativePath + }; + } + var absolutePath = String(attachmentRef.resolvedAbsolutePath || '').trim() + || fileUriToPath(attachmentRef.resolvedUri); + if (absolutePath) { + var slash = absolutePath.lastIndexOf('/'); + if (slash > 0) { + return { + rootUri: fileUriFromPath(absolutePath.slice(0, slash)), + relativePath: absolutePath.slice(slash + 1) + }; + } + } + } + var apiPath = localFileOpenPathFromHref(rawHref); + if (apiPath) { + return { + rootUri: localFileOpenRootUriFromHref(rawHref) || currentRootUri() || '', + relativePath: apiPath + }; + } + var markdownPath = localMarkdownAttachmentPathFromHref(rawHref, paneContext && paneContext.documentId); + if (markdownPath) { + return { + rootUri: currentRootUri() || '', + relativePath: markdownPath + }; + } + return null; + } + function buildPdfPreviewOpenUrl(fileUrl, fileName) { if (typeof injectedBuildPdfPreviewOpenUrl === 'function') { return injectedBuildPdfPreviewOpenUrl(fileUrl, fileName); @@ -112,24 +228,43 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } } + function setEditorAttachmentUnauthorizedState(link, unauthorized) { + if (!(link instanceof HTMLAnchorElement)) return; + var value = Boolean(unauthorized); + if (value) { + if (link.getAttribute('data-mnote-attachment-unauthorized') === 'true' && link.classList.contains('mnote-uploaded-attachment-unauthorized')) return; + setAttributeIfChanged(link, 'data-mnote-attachment-unauthorized', 'true'); + link.classList.add('mnote-uploaded-attachment-unauthorized'); + setAttributeIfChanged(link, 'aria-label', (link.textContent || '附件') + '(无权访问)'); + } else { + if (link.getAttribute('data-mnote-attachment-unauthorized') !== 'true') return; + link.removeAttribute('data-mnote-attachment-unauthorized'); + link.classList.remove('mnote-uploaded-attachment-unauthorized'); + if (link.getAttribute('data-mnote-attachment-missing') !== 'true') link.removeAttribute('aria-label'); + } + } + function setAttributeIfChanged(element, name, value) { var nextValue = String(value || ''); if (element.getAttribute(name) === nextValue) return; element.setAttribute(name, nextValue); } - function setEditorAttachmentMissingStateByHref(href, missing) { - var targetKey = localFileOpenKeyFromHref(href); + function setEditorAttachmentMissingStateByHref(href, missing, targetKeyOverride) { + var targetKey = String(targetKeyOverride || '').trim() || localFileOpenKeyFromHref(href); if (!targetKey) return; if (missing) { editorAttachmentMissingByKey.set(targetKey, true); } else { editorAttachmentMissingByKey.delete(targetKey); } - document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) { + document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) { if (!(candidate instanceof HTMLAnchorElement)) return; var candidateHref = candidate.getAttribute('href') || candidate.href || ''; - if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return; + var candidateRef = attachmentRefForHref(candidateHref, editorAttachmentPaneContext(candidate), candidate.textContent || ''); + var candidateParts = localAttachmentOpenParts(candidateHref, candidateRef, editorAttachmentPaneContext(candidate)); + var candidateKey = candidateParts ? String(candidateParts.rootUri || '').trim() + '\n' + String(candidateParts.relativePath || '').trim() : localFileOpenKeyFromHref(candidateHref); + if (candidateKey !== targetKey) return; setEditorAttachmentMissingState(candidate, missing); }); } @@ -137,35 +272,41 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { async function refreshLocalAttachmentExistence(link) { if (!(link instanceof HTMLAnchorElement)) return; var href = link.getAttribute('href') || link.href || ''; - var attachmentKey = localFileOpenKeyFromHref(href); + var paneContext = editorAttachmentPaneContext(link); + var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : ''); + var openParts = localAttachmentOpenParts(href, attachmentRef, paneContext); + var attachmentKey = openParts ? String(openParts.rootUri || '').trim() + '\n' + String(openParts.relativePath || '').trim() : localFileOpenKeyFromHref(href); if (!attachmentKey) return; var refreshSeq = (editorAttachmentRefreshSeqByKey.get(attachmentKey) || 0) + 1; editorAttachmentRefreshSeqByKey.set(attachmentKey, refreshSeq); - var localFilePath = localFileOpenPathFromHref(href); + var localFilePath = openParts ? openParts.relativePath : localFileOpenPathFromHref(href); if (!localFilePath) return; - var statusUrl = buildLocalFileStatusUrl(localFilePath, localFileOpenRootUriFromHref(href)); + var statusUrl = buildLocalFileStatusUrl(localFilePath, openParts ? openParts.rootUri : localFileOpenRootUriFromHref(href)); if (!statusUrl) return; try { var response = await fetch(statusUrl, { headers: { accept: 'application/json' }, cache: 'no-store' }); var payload = await response.json().catch(function() { return null; }); if (editorAttachmentRefreshSeqByKey.get(attachmentKey) !== refreshSeq) return; if (!response.ok || !payload || payload.ok !== true || !payload.result) { - setEditorAttachmentStatusErrorByHref(href, String(response.status || 'stat_failed')); + setEditorAttachmentStatusErrorByHref(href, String(response.status || 'stat_failed'), attachmentKey); return; } var exists = Boolean(response.ok && payload && payload.ok === true && payload.result && payload.result.exists === true); - setEditorAttachmentStatusErrorByHref(href, ''); - setEditorAttachmentMissingStateByHref(href, !exists); + setEditorAttachmentStatusErrorByHref(href, '', attachmentKey); + setEditorAttachmentMissingStateByHref(href, !exists, attachmentKey); } catch (_) {} } - function setEditorAttachmentStatusErrorByHref(href, status) { - var targetKey = localFileOpenKeyFromHref(href); + function setEditorAttachmentStatusErrorByHref(href, status, targetKeyOverride) { + var targetKey = String(targetKeyOverride || '').trim() || localFileOpenKeyFromHref(href); if (!targetKey) return; - document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) { + document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(candidate) { if (!(candidate instanceof HTMLAnchorElement)) return; var candidateHref = candidate.getAttribute('href') || candidate.href || ''; - if (localFileOpenKeyFromHref(candidateHref) !== targetKey) return; + var candidateRef = attachmentRefForHref(candidateHref, editorAttachmentPaneContext(candidate), candidate.textContent || ''); + var candidateParts = localAttachmentOpenParts(candidateHref, candidateRef, editorAttachmentPaneContext(candidate)); + var candidateKey = candidateParts ? String(candidateParts.rootUri || '').trim() + '\n' + String(candidateParts.relativePath || '').trim() : localFileOpenKeyFromHref(candidateHref); + if (candidateKey !== targetKey) return; if (status) { setAttributeIfChanged(candidate, 'data-mnote-attachment-status-error', status); } else { @@ -175,15 +316,18 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } function refreshEditorLocalAttachmentExistence() { - document.querySelectorAll('.editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) { + document.querySelectorAll(editorAttachmentEnhanceSelector + ', .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]').forEach(function(link) { if (link instanceof HTMLAnchorElement) void refreshLocalAttachmentExistence(link); }); } window.__mnoteRefreshEditorLocalAttachmentExistence = refreshEditorLocalAttachmentExistence; + var attachmentExistenceRefreshFrame = 0; function scheduleEditorLocalAttachmentExistenceRefresh() { - [120, 500, 1200].forEach(function(delayMs) { - window.setTimeout(refreshEditorLocalAttachmentExistence, delayMs); + if (attachmentExistenceRefreshFrame) return; + attachmentExistenceRefreshFrame = window.requestAnimationFrame(function() { + attachmentExistenceRefreshFrame = 0; + refreshEditorLocalAttachmentExistence(); }); } @@ -254,19 +398,57 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { }; } + function currentPageAggregate() { + var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__'); + if (!(script instanceof HTMLScriptElement)) return null; + try { + return JSON.parse(script.textContent || 'null'); + } catch (_) { + return null; + } + } + + function attachmentRefForHref(rawHref, paneContext, label) { + var aggregate = currentPageAggregate(); + var refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : []; + if (!refs.length) return null; + var href = String(rawHref || '').trim(); + var textLabel = String(label || '').trim(); + var absoluteHref = ''; + try { + absoluteHref = new URL(href, window.location.href).href; + } catch (_) {} + return refs.find(function(ref) { + if (!ref || typeof ref !== 'object') return false; + return String(ref.rawHref || '') === href + || String(ref.normalizedHref || '') === href + || String(ref.resolvedUri || '') === href + || (absoluteHref && String(ref.resolvedUri || '') === absoluteHref) + || (!href && textLabel && String(ref.label || '').trim() === textLabel); + }) || null; + } + function detailFromEditorAttachmentLink(link) { - var rawHref = link instanceof HTMLAnchorElement ? link.href : ''; + var rawHref = link instanceof HTMLAnchorElement ? (link.getAttribute('href') || '') : ''; var params = attachmentQueryParams(rawHref); var paneContext = editorAttachmentPaneContext(link); - var localFilePath = localFileOpenPathFromHref(rawHref); - var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件'; - var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, ''); + var attachmentRef = attachmentRefForHref(rawHref, paneContext, link ? link.textContent : ''); + if (!rawHref && attachmentRef) rawHref = String(attachmentRef.rawHref || attachmentRef.normalizedHref || attachmentRef.resolvedUri || '').trim(); + var openParts = localAttachmentOpenParts(rawHref, attachmentRef, paneContext); + var localFilePath = openParts && openParts.relativePath ? openParts.relativePath : ( + String(attachmentRef?.relativePath || '').trim() + || localFileOpenPathFromHref(rawHref) + || localMarkdownAttachmentPathFromHref(rawHref, paneContext.documentId) + ); + var localRootUri = openParts && openParts.rootUri ? openParts.rootUri : currentRootUri(); + var fileName = params.get('fileName') || String(attachmentRef?.label || '').trim() || fileNameFromPath(localFilePath) || (link ? link.textContent : '') || '未命名附件'; + var fileType = params.get('fileType') || inferOnlyOfficeFileType(fileName, '') || String(attachmentRef?.ext || '').trim(); var assetId = params.get('assetId') || (link ? link.getAttribute('data-asset-id') : '') || (localFilePath ? 'local-file:' + localFilePath : ''); - var fileUrl = params.get('fileUrl') || ''; + var fileUrl = params.get('fileUrl') || (localFilePath ? buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false) : '') || String(attachmentRef?.resolvedUri || '').trim(); var documentId = params.get('documentId') || paneContext.documentId || ''; var href = rawHref; if (!isOnlyOfficeAttachmentHref(rawHref) && fileType) { - fileUrl = rawHref; + fileUrl = localFilePath ? buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false) : rawHref; href = buildOnlyOfficeOpenUrl({ fileUrl: fileUrl, fileName: fileName, @@ -289,33 +471,53 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { documentId: documentId, workspaceId: paneContext.workspaceId, paneRole: paneContext.paneRole, - fileSize: (link ? link.getAttribute('data-file-size') : '') || '' + localRootUri: localRootUri, + localRelativePath: localFilePath, + fileSize: (link ? link.getAttribute('data-file-size') : '') || '', + attachmentRef: attachmentRef || null, + authorized: typeof attachmentRef?.authorized === 'boolean' ? attachmentRef.authorized : null, + exists: typeof attachmentRef?.exists === 'boolean' ? attachmentRef.exists : null, + openKind: String(attachmentRef?.openKind || '').trim() }; } - function enhanceEditorAttachmentLink(link) { if (!(link instanceof HTMLAnchorElement)) return; var href = link.getAttribute('href') || ''; var params = attachmentQueryParams(href); - var localFilePath = localFileOpenPathFromHref(href); var paneContext = editorAttachmentPaneContext(link); - var fileName = params.get('fileName') || fileNameFromPath(localFilePath) || link.textContent || ''; + var attachmentRef = attachmentRefForHref(href, paneContext, link ? link.textContent : ''); + if (!href && attachmentRef) href = String(attachmentRef.rawHref || attachmentRef.normalizedHref || attachmentRef.resolvedUri || '').trim(); + var openParts = localAttachmentOpenParts(href, attachmentRef, paneContext); + var localFilePath = openParts && openParts.relativePath ? openParts.relativePath : (localFileOpenPathFromHref(href) || localMarkdownAttachmentPathFromHref(href, paneContext.documentId)); + var resolvedPathName = fileNameFromPath(localFilePath || fileUriToPath(attachmentRef && attachmentRef.resolvedUri)); + var fileName = params.get('fileName') || resolvedPathName || link.textContent || ''; var className = link.getAttribute('class') || ''; var shouldEnhance = isOnlyOfficeAttachmentHref(href) || className.indexOf('mnote-uploaded-attachment-row') >= 0 || isOfficeFileName(fileName) || Boolean(localFilePath); if (!shouldEnhance) return; - setAttributeIfChanged(link, 'data-mnote-attachment-link', 'true'); var assetId = params.get('assetId') || link.getAttribute('data-asset-id') || (localFilePath ? 'local-file:' + localFilePath : ''); if (assetId) setAttributeIfChanged(link, 'data-asset-id', assetId); attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { if (name) link.classList.add(name); }); + if (attachmentRef && typeof attachmentRef === 'object') { + if (typeof attachmentRef.exists === 'boolean') { + setEditorAttachmentMissingState(link, attachmentRef.exists === false); + } + if (typeof attachmentRef.authorized === 'boolean') { + setEditorAttachmentUnauthorizedState(link, attachmentRef.authorized === false); + } + } setAttributeIfChanged(link, 'target', '_blank'); setAttributeIfChanged(link, 'rel', 'noopener noreferrer nofollow'); if (localFilePath && !isOnlyOfficeAttachmentHref(href)) { - setEditorAttachmentMissingState(link, editorAttachmentMissingByKey.has(localFileOpenKeyFromHref(href))); + var localAttachmentKey = openParts + ? String(openParts.rootUri || '').trim() + '\n' + String(openParts.relativePath || '').trim() + : localFileOpenKeyFromHref(href); + var isProjectionMissing = typeof attachmentRef?.exists === 'boolean' && attachmentRef.exists === false; + setEditorAttachmentMissingState(link, isProjectionMissing || editorAttachmentMissingByKey.has(localAttachmentKey)); void refreshLocalAttachmentExistence(link); return; } @@ -334,26 +536,12 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } function enhanceEditorAttachmentLinks() { - document.querySelectorAll('.editor-surface .ProseMirror a[href]').forEach(enhanceEditorAttachmentLink); - void healLegacyOfficeAttachmentParagraphs(); + document.querySelectorAll(editorAttachmentEnhanceSelector).forEach(enhanceEditorAttachmentLink); } window.__mnoteEnhanceEditorAttachmentLinks = function() { observeEditorAttachmentRoots(); enhanceEditorAttachmentLinks(); }; - var attachmentInitialEnhanceAttempts = 0; - var attachmentInitialEnhanceTimer = window.setInterval(function() { - attachmentInitialEnhanceAttempts += 1; - enhanceEditorAttachmentLinks(); - if (attachmentInitialEnhanceAttempts >= 120) window.clearInterval(attachmentInitialEnhanceTimer); - }, 500); - var attachmentExistenceRefreshTimer = window.setInterval(function() { - refreshEditorLocalAttachmentExistence(); - }, 2500); - window.addEventListener('beforeunload', function() { - if (attachmentExistenceRefreshTimer) window.clearInterval(attachmentExistenceRefreshTimer); - attachmentExistenceRefreshTimer = 0; - }); var lastEditorAttachmentMouseOpen = { at: 0, href: '' }; function ensureAttachmentActions() { @@ -394,25 +582,53 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } async function openEditorAttachmentDetail(detail) { - if (!detail || !detail.href) return; - var localFilePath = localFilePathFromAssetId(detail.assetId); + if (!detail) return; + if (detail.authorized === false) { + document.documentElement.setAttribute('data-mnote-attachment-open-blocked', 'unauthorized'); + return; + } + if (detail.exists === false) { + document.documentElement.setAttribute('data-mnote-attachment-open-blocked', 'missing'); + return; + } + if (!detail.href) return; + document.documentElement.removeAttribute('data-mnote-attachment-open-blocked'); + var localFilePath = localFilePathFromAssetId(detail.assetId) || String(detail.localRelativePath || '').trim(); if (localFilePath) { - if (await openLocalOfficeFileInActiveTab(detail, 'view')) return; + var localRootUri = String(detail.localRootUri || currentRootUri() || '').trim(); + if (!detail.localRootUri && await openLocalOfficeFileInActiveTab(detail, 'view')) return; var localFileName = detail.fileName || localFilePath.split('/').pop() || localFilePath; - var localFileUrl = buildLocalFileOpenUrl(localFilePath, false); + var localFileUrl = buildLocalFileOpenUrlForRoot(localFilePath, localRootUri, false); var localOpenUrl = isPdfAttachmentFileName(localFileName) ? buildPdfPreviewOpenUrl(localFileUrl, localFileName) : localFileUrl; + var localOfficeType = inferOnlyOfficeFileType(localFileName, ''); + var localOfficeUrl = localOfficeType + ? buildOnlyOfficeOpenUrl({ + fileUrl: localFileUrl, + fileName: localFileName, + fileType: localOfficeType, + assetId: detail.assetId || ('local-file:' + localFilePath), + documentId: detail.documentId || currentDocumentId() || '', + userId: '', + mode: 'view', + workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', + sourceKind: 'local_folder', + rootUri: localRootUri + }) + : ''; // 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口 void openLocalResourceInActiveTab({ path: localFilePath, title: localFileName, - kind: fileTreeIconKindForFileName(localFileName), + kind: localOfficeType ? 'office' : fileTreeIconKindForFileName(localFileName), assetId: detail.assetId, documentId: detail.documentId || currentDocumentId() || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', href: localOpenUrl, + officeUrl: localOfficeUrl, + rootUri: localRootUri, paneRole: detail.paneRole || 'primary' }).then(function(opened) { - if (!opened) window.open(localOpenUrl || detail.href, '_blank', 'noopener,noreferrer'); + if (!opened) window.open(localOfficeUrl || localOpenUrl || detail.href, '_blank', 'noopener,noreferrer'); }); return; } @@ -631,9 +847,9 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { async function openEditorAttachmentDownload(detail) { if (!detail) return; - var localFilePath = localFilePathFromAssetId(detail.assetId); + var localFilePath = localFilePathFromAssetId(detail.assetId) || String(detail.localRelativePath || '').trim(); if (localFilePath) { - var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true); + var localDownloadUrl = buildLocalFileOpenUrlForRoot(localFilePath, detail.localRootUri || currentRootUri(), true); if (localDownloadUrl) { triggerBrowserDownload(localDownloadUrl); return; @@ -693,8 +909,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { } function addedNodeMayContainEditorAttachmentLink(node) { return node instanceof HTMLElement && ( - node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div') - || node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href]') + node.matches('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror p, .editor-surface .ProseMirror span, .editor-surface .ProseMirror div') + || node.querySelector?.('.editor-surface .ProseMirror, .editor-surface .ProseMirror a[href], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row') ); } function observeEditorAttachmentRoots() { @@ -706,10 +922,34 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { attachmentEditorObserver.observe(editor, { childList: true, subtree: true }); }); } + var attachmentAggregateObserver = null; + var attachmentObservedAggregateScript = null; + function observePageAggregateScript() { + if (typeof MutationObserver !== 'function') return; + var script = document.getElementById('__MNOTE_PAGE_AGGREGATE__'); + if (!(script instanceof HTMLScriptElement)) return; + if (script === attachmentObservedAggregateScript) return; + if (attachmentAggregateObserver) attachmentAggregateObserver.disconnect(); + attachmentObservedAggregateScript = script; + attachmentAggregateObserver = new MutationObserver(function() { + scheduleEditorAttachmentEnhance(); + scheduleEditorLocalAttachmentExistenceRefresh(); + }); + attachmentAggregateObserver.observe(script, { + attributes: true, + characterData: true, + childList: true, + subtree: true + }); + } attachmentEditorObserver = new MutationObserver(function(records) { var shouldEnhance = Array.isArray(records) && records.some(function(record) { if (!record || record.type !== 'childList') return false; return Array.from(record.addedNodes || []).some(function(node) { + if (node instanceof HTMLElement && node.id === '__MNOTE_PAGE_AGGREGATE__') { + observePageAggregateScript(); + return true; + } return addedNodeMayContainEditorAttachmentLink(node); }); }); @@ -718,10 +958,21 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { }); attachmentEditorObserver.observe(document.documentElement, { childList: true, subtree: true }); observeEditorAttachmentRoots(); + observePageAggregateScript(); window.addEventListener('mnote:editor-attachment-links-changed', function() { window.__mnoteEnhanceEditorAttachmentLinks(); scheduleEditorAttachmentEnhance(); }); + window.addEventListener('mnote:page-aggregate-synced', function() { + window.__mnoteEnhanceEditorAttachmentLinks(); + scheduleEditorAttachmentEnhance(); + scheduleEditorLocalAttachmentExistenceRefresh(); + }); + window.addEventListener('mnote:primary-document-activated', function() { + window.__mnoteEnhanceEditorAttachmentLinks(); + scheduleEditorAttachmentEnhance(); + scheduleEditorLocalAttachmentExistenceRefresh(); + }); window.addEventListener('tree:delta', scheduleEditorLocalAttachmentExistenceRefresh); window.addEventListener('tree:resync', scheduleEditorLocalAttachmentExistenceRefresh); [ @@ -732,18 +983,8 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => { window.addEventListener(eventName, function() { enhanceEditorAttachmentLinks(); observeEditorAttachmentRoots(); - window.setTimeout(function() { - enhanceEditorAttachmentLinks(); - observeEditorAttachmentRoots(); - }, 120); }, true); }); - [120, 500, 1200, 2500].forEach(function(delayMs) { - window.setTimeout(function() { - enhanceEditorAttachmentLinks(); - observeEditorAttachmentRoots(); - }, delayMs); - }); function interceptEditorAttachmentLink(event) { var editorAttachmentLink = closestAction(event.target, editorAttachmentLinkSelector); diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js index c08df4fc..68630df7 100644 --- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js @@ -1028,20 +1028,35 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { function selectSidebarFileTreeDocument(documentId, options) { var id = String(documentId || '').trim(); if (!id) return false; - var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]') + var relativePath = options && options.relativePath + ? String(options.relativePath || '').trim() + : id.indexOf('local-md:') === 0 + ? decodeLocalEncodedPath(id.slice('local-md:'.length)) + : ''; + var targetRowId = options && options.rowId ? String(options.rowId || '').trim() : ''; + var row = targetRowId + ? document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(targetRowId) + '"]') + : null; + if (!(row instanceof HTMLElement)) { + row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]') || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]') || document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]'); + } + if (row instanceof HTMLElement && relativePath && fileTreeRowLocalRelativePath(row) !== relativePath) { + row = null; + } + if (row instanceof HTMLElement && row.closest('.tree-children--collapsed') && relativePath && typeof revealFileTreeResource === 'function') { + void revealFileTreeResource({ + rootUri: options && options.rootUri, + relativePath: relativePath, + rowId: options && options.rowId, + select: true, + focus: true, + scroll: options ? options.scrollIntoView !== false : true + }); + return true; + } if (!(row instanceof HTMLElement)) { - var relativePath = options && options.relativePath - ? String(options.relativePath || '').trim() - : id.indexOf('local-md:') === 0 - ? decodeLocalEncodedPath(id.slice('local-md:'.length)) - : ''; - var bundleParentPath = localMarkdownBundleParentPath(relativePath); - var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null; - if (bundleRow instanceof HTMLElement) { - return activateSidebarFileTreeRow(bundleRow, options); - } if (relativePath && typeof revealFileTreeResource === 'function') { void revealFileTreeResource({ rootUri: options && options.rootUri, @@ -1050,9 +1065,19 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { select: true, focus: true, scroll: options ? options.scrollIntoView !== false : true - }); + }).then(function(revealed) { + if (revealed) return; + var fallbackParentPath = localMarkdownBundleParentPath(relativePath); + var fallbackBundleRow = fallbackParentPath ? visibleFileTreeRowByRelativePath(fallbackParentPath) : null; + if (fallbackBundleRow instanceof HTMLElement) activateSidebarFileTreeRow(fallbackBundleRow, options); + }).catch(function() {}); return true; } + var bundleParentPath = localMarkdownBundleParentPath(relativePath); + var bundleRow = bundleParentPath ? visibleFileTreeRowByRelativePath(bundleParentPath) : null; + if (bundleRow instanceof HTMLElement) { + return activateSidebarFileTreeRow(bundleRow, options); + } return false; } return activateSidebarFileTreeRow(row, options); @@ -1116,25 +1141,33 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => { } document.documentElement.setAttribute('data-mnote-local-folder-restore-focused-row-id', rowId); document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'focused'); + clearPendingLocalFolderRestoreRowId(); return true; } - function schedulePendingLocalFolderRestoreFocus() { + function schedulePendingLocalFolderRestoreFocus(options) { if (!pendingLocalFolderRestoreRowId()) return false; - if (window.__mnotePendingLocalFolderRestoreFocusTimer) return false; - var attempts = 0; - var focused = false; - focused = applyPendingLocalFolderRestoreFocusOnce() || focused; - window.__mnotePendingLocalFolderRestoreFocusTimer = window.setInterval(function() { - attempts += 1; - focused = applyPendingLocalFolderRestoreFocusOnce() || focused; - if (attempts >= 20) { - if (focused) clearPendingLocalFolderRestoreRowId(); - else document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'timeout'); - window.clearInterval(window.__mnotePendingLocalFolderRestoreFocusTimer); - window.__mnotePendingLocalFolderRestoreFocusTimer = 0; - } - }, 250); + var reason = options && options.reason ? String(options.reason) : ''; + var attempt = Math.max(0, Number(options && options.attempt || 0)); + var focused = applyPendingLocalFolderRestoreFocusOnce(); + if (focused) return true; + if (attempt >= 4) { + document.documentElement.setAttribute('data-mnote-local-folder-restore-focus-status', 'timeout'); + return false; + } + if (window.__mnotePendingLocalFolderRestoreFocusFrame) { + window.cancelAnimationFrame(window.__mnotePendingLocalFolderRestoreFocusFrame); + window.__mnotePendingLocalFolderRestoreFocusFrame = 0; + } + window.__mnotePendingLocalFolderRestoreFocusFrame = window.requestAnimationFrame(function() { + window.__mnotePendingLocalFolderRestoreFocusFrame = 0; + window.setTimeout(function() { + schedulePendingLocalFolderRestoreFocus({ + reason: reason || 'retry', + attempt: attempt + 1, + }); + }, 60); + }); return focused; } diff --git a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js index 56052569..c611cd84 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-tree-runtime.js @@ -26,6 +26,21 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { var draggingPageNodeId = ''; var activePageDropRow = null; + function localCreateSelectionOptions(result) { + var selectTarget = result && result.selectTarget && typeof result.selectTarget === 'object' + ? result.selectTarget + : result && result.revealTarget && typeof result.revealTarget === 'object' + ? result.revealTarget + : null; + if (!selectTarget) return { scrollIntoView: true }; + return { + scrollIntoView: true, + rootUri: result.rootUri || '', + relativePath: selectTarget.relativePath || selectTarget.relative_path || '', + rowId: selectTarget.rowId || selectTarget.row_id || '' + }; + } + function navigateToDocument(nodeId, workspaceId, options) { if (!nodeId) return; var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode()); @@ -111,7 +126,7 @@ export const createSidebarPageTreeRuntime = (dependencies = {}) => { if (typeof refreshLocalFolderAfterCommand === 'function') { await refreshLocalFolderAfterCommand('create', result, { parentId: effectiveParentId || null }); } - selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true }); + selectSidebarFileTreeDocument(nextDocumentId, localCreateSelectionOptions(result)); document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId); } navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() }); diff --git a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js index 56ad0f76..24321651 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js @@ -423,17 +423,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { void refreshLocalFolderSidebarSnapshot(); return true; } + var changedPaths = Array.isArray(batch && batch.changedPaths) + ? batch.changedPaths + : Array.isArray(batch && batch.changed_paths) + ? batch.changed_paths + : []; var affectedParents = Array.isArray(batch && batch.affectedParents) ? batch.affectedParents : Array.isArray(batch && batch.affected_parents) ? batch.affected_parents : []; if (!affectedParents.length) { - var changedPaths = Array.isArray(batch && batch.changedPaths) - ? batch.changedPaths - : Array.isArray(batch && batch.changed_paths) - ? batch.changed_paths - : []; affectedParents = changedPaths.map(function(item) { var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim(); return { relativePath: parentRelativePathForPath(relativePath), reason: 'derived-from-changed-path' }; @@ -452,6 +452,10 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { affectedParents.forEach(function(parent) { addCommandRefreshParent(parents, parent && (parent.relativePath || parent.relative_path)); }); + var needsSidebarRefresh = changedPaths.some(function(item) { + var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim().toLowerCase(); + return relativePath.endsWith('.md') || relativePath.endsWith('.markdown'); + }); document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-applied', String(batch.revision || 'true')); void Promise.all(Array.from(parents).map(function(parentRelativePath) { return refreshFileTreeParent(parentRelativePath).catch(function(error) { @@ -459,6 +463,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return false; }); })).then(function() { + if (needsSidebarRefresh) { + void refreshLocalFolderSidebarSnapshot(); + } markLocalFolderWatchApplied('watch_batch'); }); return true; @@ -1391,6 +1398,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (runtimeFn) return runtimeFn(value); var appliedValue = String(value || 'projection'); document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', appliedValue); + document.documentElement.removeAttribute('data-mnote-local-folder-watch-disabled'); return true; } @@ -1492,55 +1500,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (!rootUri) return; ensureFileTreeLazyCacheScope(); scheduleRestorePersistedFileTreeExpansionState(); - var revision = ''; - var refreshTimer = 0; - var treeLiveEventsActive = function() { - var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || ''; - return treeTransport === 'local-folder-events'; - }; - var scheduleRefresh = function() { - if (treeLiveEventsActive()) return; - if (refreshTimer) return; - refreshTimer = window.setTimeout(function() { - refreshTimer = 0; - if (treeLiveEventsActive()) return; - var fileTreeScope = currentFileTreeScope(); - if (fileTreeScope) { - markFileTreeParentStale(currentFileTreeParentKey(fileTreeScope)); - markLocalFolderWatchApplied('scope_stale'); - document.documentElement.setAttribute('data-mnote-filetree-scope-watch-stale', fileTreeScope); - return; - } - void refreshLocalFolderSidebarSnapshot(); - }, 180); - }; - var poll = async function() { - if (document.hidden) return; - // If tree live SSE transport is active for local_folder, skip polling (fallback) - if (treeLiveEventsActive()) return; - var url = new URL('/api/tree/local-folder-watch', window.location.origin); - url.searchParams.set('rootUri', rootUri); - var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); - if (!response.ok) return; - if (treeLiveEventsActive()) return; - var payload = await response.json(); - var nextRevision = payload && payload.result && typeof payload.result.revision === 'string' - ? payload.result.revision - : ''; - if (!nextRevision) return; - if (!revision) { - revision = nextRevision; - return; - } - if (nextRevision !== revision) { - revision = nextRevision; - scheduleRefresh(); - } - }; - window.setInterval(function() { - void poll(); - }, 1200); - void poll(); + var treeTransport = document.documentElement.getAttribute('data-mnote-tree-live-transport') || ''; + if (treeTransport !== 'local-folder-events') { + document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'static'); + document.documentElement.setAttribute('data-mnote-local-folder-watch-disabled', 'events-required'); + } } function isTitleOnlyDocumentPatch(candidate) { diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index dd24a779..4a89e684 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -961,6 +961,22 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim(); } + function uploadedAssetMarkdownHref(asset) { + var runtimeFn = localUploadRuntimeFunction('uploadedAssetMarkdownHref'); + if (runtimeFn) return runtimeFn(asset); + var href = String(asset && (asset.markdownHref || asset.markdown_href) || '').trim(); + if (href) { + var normalizedHref = href.replace(/\\/g, '/'); + if (normalizedHref.indexOf('../') === 0 || normalizedHref.indexOf('/../') >= 0) return ''; + return href; + } + var relativePath = String(asset && (asset.markdownRelativePath || asset.markdown_relative_path) || '').trim().replace(/\\/g, '/'); + if (!relativePath) return ''; + if (relativePath.indexOf('../') === 0 || relativePath.indexOf('/../') >= 0) return ''; + if (relativePath.indexOf('./') === 0) return relativePath; + return './' + relativePath; + } + function localAssetOpenUrl(asset, download) { var runtimeFn = localUploadRuntimeFunction('localAssetOpenUrl'); if (runtimeFn) return runtimeFn(asset, download, { rootUri: currentRootUri() }); @@ -1238,7 +1254,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim userId: '', mode: 'view' })); - link.setAttribute('data-mnote-attachment-link', 'true'); link.setAttribute('data-asset-id', detail.assetId); attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) { if (name) link.classList.add(name); @@ -1733,7 +1748,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return false; } var title = uploadedAssetTitle(asset); - var url = localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset); + var markdownHref = uploadedAssetMarkdownHref(asset); + var url = markdownHref || localAssetOpenUrl(asset, false) || uploadedAssetUrl(asset); var type = uploadedAssetType(asset); var assetId = String(asset && asset.id || '').trim(); var sizeLabel = uploadedFileSize(asset); @@ -1741,26 +1757,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim if (type === 'image' && url) { return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true; } - var isLocalAsset = isLocalUploadedAsset(asset); - var userId = ''; - var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); - if (onlyOfficeUrl && assetId) { - userId = await fetchCurrentOnlyOfficeUserId(); - onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId); - } - var href = onlyOfficeUrl || url; + var href = markdownHref || url; if (href) { - var storedHref = onlyOfficeUrl - ? (isLocalAsset ? onlyOfficeUrl : buildOnlyOfficeOpenPath({ - fileUrl: '', - fileName: title, - fileType: inferOnlyOfficeFileType(title, asset && asset.mime_type) || 'docx', - assetId: assetId, - documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(), - userId: userId || '', - mode: 'view' - })) - : href; var inserted = editor.chain().focus().insertContent([ { type: 'paragraph', @@ -1770,7 +1768,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim marks: [{ type: 'link', attrs: { - href: storedHref, + href: href, target: '_blank', rel: 'noopener noreferrer nofollow', class: uploadedAttachmentClass(asset) @@ -1806,7 +1804,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim }) || null; } if (link instanceof HTMLElement) { - link.setAttribute('data-mnote-attachment-link', 'true'); if (assetId) link.setAttribute('data-asset-id', assetId); if (sizeLabel) link.setAttribute('data-file-size', sizeLabel); } else if (Number(attempt) < 20) { @@ -2452,7 +2449,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return; } - var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]'); + var editorAttachmentLink = closestAction(e.target, '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); if (editorAttachmentLink instanceof HTMLAnchorElement) { e.preventDefault(); openEditorAttachmentLink(editorAttachmentLink); @@ -2969,7 +2966,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim }); document.addEventListener('contextmenu', function(event) { - var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row, .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"], .editor-surface .ProseMirror a[href*="/office-preview"]'); + var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a.mnote-uploaded-attachment-row'); if (editorAttachmentLink instanceof HTMLAnchorElement) { event.preventDefault(); event.stopPropagation(); diff --git a/rust/crates/mnote-web/browser/tree-live-controller.js b/rust/crates/mnote-web/browser/tree-live-controller.js index a479e7b8..d16623ef 100644 --- a/rust/crates/mnote-web/browser/tree-live-controller.js +++ b/rust/crates/mnote-web/browser/tree-live-controller.js @@ -186,8 +186,9 @@ return; } var params = new URLSearchParams(window.location.search); - var sourceKind = (params.get('sourceKind') || '').trim(); - if (sourceKind === 'local_folder') { + var sourceKind = (params.get('sourceKind') || '').trim() + || (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-source-kind') || '').trim() : ''); + if (sourceKind === 'local_folder' || bootstrap.transport === 'local-folder-events') { applyTransport('local-folder-events'); applyStatus('connecting'); var localRootUri = (params.get('rootUri') || '').trim() @@ -199,7 +200,7 @@ startWithSse(bootstrap, '', url); return; } - // No rootUri or EventSource unavailable — mark static and let polling fallback handle it + // No rootUri or EventSource unavailable — stay static and let caller surface events-required applyTransport('local-folder-static'); applyStatus('static'); return; diff --git a/rust/crates/mnote-web/browser/tree-shell-runtime.js b/rust/crates/mnote-web/browser/tree-shell-runtime.js index 93ac4539..c2ba8ac3 100644 --- a/rust/crates/mnote-web/browser/tree-shell-runtime.js +++ b/rust/crates/mnote-web/browser/tree-shell-runtime.js @@ -979,6 +979,11 @@ function startTreeShellRuntime() { }); if (!response.ok) return false; const nextState = parseTreeShellStateFromHtml(await response.text()); + document.documentElement.setAttribute( + "data-mnote-local-folder-watch-applied", + options.appliedValue || "projection", + ); + document.documentElement.removeAttribute("data-mnote-local-folder-watch-disabled"); return applyTreeShellStateSnapshot(nextState, options); }; @@ -988,6 +993,16 @@ function startTreeShellRuntime() { }, 80); }; + let localFolderEventRefreshPending = false; + const scheduleLocalFolderEventRefresh = (options = {}) => { + if (localFolderEventRefreshPending) return; + localFolderEventRefreshPending = true; + window.setTimeout(() => { + localFolderEventRefreshPending = false; + void refreshLocalFolderSnapshot(options); + }, 120); + }; + const addTreeItemLocally = (item) => { if (!item?.nodeId || itemById.has(item.nodeId)) return false; normalizedItems.push(item); @@ -1096,41 +1111,49 @@ function startTreeShellRuntime() { }; if (sourceKind === "local_folder" && rootUri) { - let localWatchRevision = initialLocalWatchRevision; - let localWatchRefreshTimer = 0; - const refreshFromLocalWatch = () => { - if (localWatchRefreshTimer) return; - localWatchRefreshTimer = window.setTimeout(() => { - localWatchRefreshTimer = 0; - void refreshLocalFolderSnapshot(); - }, 180); + let localFolderWatchRevision = initialLocalWatchRevision; + const applyLocalFolderRevision = (revision) => { + const nextRevision = normalizeText(revision); + if (!nextRevision || nextRevision === localFolderWatchRevision) return false; + localFolderWatchRevision = nextRevision; + document.documentElement.setAttribute("data-mnote-tree-live-revision", nextRevision); + return true; }; - const pollLocalFolderRevision = async () => { - if (busy || document.hidden) return; - const url = new URL("/api/tree/local-folder-watch", window.location.origin); - url.searchParams.set("rootUri", rootUri); - const response = await fetch(url.toString(), { headers: { "accept": "application/json" } }); - if (!response.ok) return; - const payload = await response.json(); - const nextRevision = - payload && - payload.result && - typeof payload.result.revision === "string" - ? payload.result.revision - : ""; - if (!nextRevision) return; - if (!localWatchRevision) { - localWatchRevision = nextRevision; - return; - } - if (nextRevision !== localWatchRevision) { - localWatchRevision = nextRevision; - refreshFromLocalWatch(); - } - }; - window.setInterval(() => { - void pollLocalFolderRevision(); - }, 1200); + window.addEventListener("tree:snapshot", function(event) { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail; + if (!payload || payload.sourceKind !== "local_folder") return; + if (!applyLocalFolderRevision(payload.revision || detail.revision)) return; + scheduleLocalFolderEventRefresh({ appliedValue: "snapshot" }); + }); + window.addEventListener("tree:resync", function(event) { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail; + if (!payload || payload.sourceKind !== "local_folder") return; + if (!applyLocalFolderRevision(payload.revision || detail.revision)) return; + scheduleLocalFolderEventRefresh({ appliedValue: "resync" }); + }); + window.addEventListener("tree:local-folder-watch-batch", function(event) { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail; + if (!payload || payload.sourceKind !== "local_folder") return; + if (!applyLocalFolderRevision(payload.revision || detail.revision)) return; + scheduleLocalFolderEventRefresh({ appliedValue: "watch_batch" }); + }); + window.addEventListener("tree:error", function(event) { + const detail = event && event.detail ? event.detail : {}; + const payload = detail.payload || detail; + if (!payload || payload.sourceKind !== "local_folder") return; + document.documentElement.setAttribute( + "data-mnote-tree-live-error", + normalizeText(payload.code || payload.error || payload.message, "tree_live_error"), + ); + }); + const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || ""; + if (transport !== "local-folder-events") { + document.documentElement.setAttribute("data-mnote-local-folder-watch-applied", "static"); + document.documentElement.setAttribute("data-mnote-local-folder-watch-disabled", "events-required"); + } } const readErrorMessage = async (response) => { diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index 5ed0f267..5839e182 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -4,12 +4,16 @@ use crate::editor_actor::EditorRuntimeActor; use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; +use axum::extract::Request; +use axum::middleware::Next; +use axum::response::Response; use axum::Router; use control_plane::{ControlPlaneStore, SqliteControlPlaneStore}; use std::env; use std::fs; use std::sync::Arc; use tower_http::trace::TraceLayer; +use tracing::{error, warn}; #[derive(Debug, Clone)] pub struct AppConfig { @@ -192,10 +196,43 @@ fn open_control_plane_store() -> SqliteControlPlaneStore { pub fn build_app(state: AppState) -> Router { build_router(state) - .layer(TraceLayer::new_for_http()) + .layer(TraceLayer::new_for_http().on_failure(())) + .layer(axum::middleware::from_fn(log_failed_response)) .layer(axum::middleware::from_fn(inject_request_context)) } +async fn log_failed_response(request: Request, next: Next) -> Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + let response = next.run(request).await; + let status = response.status(); + if status.is_server_error() { + let error_code = response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if error_code == "convex_retired" { + warn!( + method = %method, + uri = %uri, + status = %status, + error_code = %error_code, + "退役 Convex 兼容路径被请求" + ); + } else { + error!( + method = %method, + uri = %uri, + status = %status, + error_code = %error_code, + "mnote-web 请求返回服务端错误" + ); + } + } + response +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/mnote-web/src/document_buffer_store.rs b/rust/crates/mnote-web/src/document_buffer_store.rs index 6e1770d6..39ff0ef8 100644 --- a/rust/crates/mnote-web/src/document_buffer_store.rs +++ b/rust/crates/mnote-web/src/document_buffer_store.rs @@ -556,7 +556,10 @@ mod tests { Some("save:op:abc".into()), ) .expect("保存后应更新 buffer"); - assert_eq!(saved.last_write_intent_id.as_deref(), Some("intent:editor:abc")); + assert_eq!( + saved.last_write_intent_id.as_deref(), + Some("intent:editor:abc") + ); assert_eq!(saved.last_save_operation_id.as_deref(), Some("save:op:abc")); let outcome = store @@ -566,7 +569,10 @@ mod tests { Some("external-editor".into()), ) .expect("watcher 回声应返回 outcome"); - assert!(outcome.self_write_echo, "同版本 watcher 回声应标记为自写回声"); + assert!( + outcome.self_write_echo, + "同版本 watcher 回声应标记为自写回声" + ); assert_eq!( outcome.buffer.last_write_intent_id.as_deref(), Some("intent:editor:abc") diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs index 816a4046..8e13817c 100644 --- a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -5,7 +5,7 @@ use crate::routes::{ }; use notify::event::ModifyKind; use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -419,12 +419,12 @@ fn system_time_ms(time: SystemTime) -> u128 { #[cfg(test)] mod tests { use super::{ - LocalFolderWatcherRegistry, is_local_search_index_path, - refresh_local_search_index_for_event, should_emit_event_kind, + is_local_search_index_path, refresh_local_search_index_for_event, should_emit_event_kind, + LocalFolderWatcherRegistry, }; use crate::document_buffer_store::BufferStore; - use notify::EventKind; use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind}; + use notify::EventKind; fn test_root(name: &str) -> std::path::PathBuf { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index 2c244df4..836a4424 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -351,6 +351,7 @@ impl PageAggregateBuilder { block_document: block_document.unwrap_or(Value::Null), block_projection_version, projection_source: "builder.content".into(), + attachment_refs: Value::Array(Vec::new()), }, tree: PageTree { page_subtree: self.page_subtree, diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index 5fcc914a..ad29e7e0 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -4,12 +4,12 @@ use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, }; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,7 +152,7 @@ pub async fn trace( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index c6ad552d..0357ef86 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -2,16 +2,16 @@ use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::{ - RetiredCloudCommandExecution, execute_retired_command_plan, - execute_retired_command_plan_with_artifacts, + execute_retired_command_plan, execute_retired_command_plan_with_artifacts, + RetiredCloudCommandExecution, }; use bridge_runtime::{ - RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, + execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, - RuntimeTargetWire, execute_runtime_input, + RuntimeTargetWire, }; -use serde_json::Value; use serde_json::json; +use serde_json::Value; pub fn runtime_context( context: &RequestContext, diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index e674cab8..b41be534 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -56,7 +56,7 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; diff --git a/rust/crates/mnote-web/src/routes/dev_hot.rs b/rust/crates/mnote-web/src/routes/dev_hot.rs index 963a6bb3..b01855e3 100644 --- a/rust/crates/mnote-web/src/routes/dev_hot.rs +++ b/rust/crates/mnote-web/src/routes/dev_hot.rs @@ -1,6 +1,6 @@ -use axum::Json; use axum::http::{HeaderMap, HeaderValue}; use axum::response::IntoResponse; +use axum::Json; use serde::Serialize; use std::sync::LazyLock; use std::time::{SystemTime, UNIX_EPOCH}; @@ -34,6 +34,10 @@ pub fn dev_hot_reload_enabled() -> bool { ) } +pub fn dev_hot_cache_buster() -> Option<&'static str> { + dev_hot_reload_enabled().then_some(DEV_HOT_BOOT_ID.as_str()) +} + pub async fn hot_reload() -> impl IntoResponse { let mut headers = HeaderMap::new(); headers.insert( diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index b42ddda7..6bdab492 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -13,15 +13,15 @@ use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id, }; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::fs; use std::time::Duration; @@ -1036,10 +1036,9 @@ pub async fn title( }), preflight_data: None, reason: Some("mnote-web page title update".into()), - refs: vec![ - body.command_name - .unwrap_or_else(|| "page.head.updateTitle".into()), - ], + refs: vec![body + .command_name + .unwrap_or_else(|| "page.head.updateTitle".into())], dry_run: false, validate_only: false, }; @@ -1141,10 +1140,9 @@ pub async fn options( }), preflight_data: None, reason: Some("mnote-web page layout update".into()), - refs: vec![ - body.command_name - .unwrap_or_else(|| "page.layout.updateOptions".into()), - ], + refs: vec![body + .command_name + .unwrap_or_else(|| "page.layout.updateOptions".into())], dry_run: false, validate_only: false, }; @@ -1181,9 +1179,9 @@ pub async fn options( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use crate::document_buffer_store::{BufferStore, build_local_folder_workspace_path}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore}; + use axum::body::{to_bytes, Body}; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use serde_json::Value; use tower::util::ServiceExt; @@ -1515,7 +1513,8 @@ mod tests { "upsert_document" ); assert_eq!( - payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"]["title"], + payload["meta"]["artifacts"]["domainEvent"]["payload"]["streamDelta"]["document"] + ["title"], "服务端页面(改名)" ); assert_eq!(payload["meta"]["artifactError"], Value::Null); @@ -1796,19 +1795,15 @@ mod tests { payload["details"]["conflict"]["editorBaseVersion"].as_str(), Some(stale_file_version.as_str()) ); - assert!( - payload["details"]["conflict"]["currentDiskVersion"] - .as_str() - .unwrap_or("") - .starts_with("local-md:local-md:README.md:") - ); - assert!( - payload["details"]["conflict"]["suggestedActions"] - .as_array() - .unwrap() - .iter() - .any(|action| action.as_str() == Some("merge")) - ); + assert!(payload["details"]["conflict"]["currentDiskVersion"] + .as_str() + .unwrap_or("") + .starts_with("local-md:local-md:README.md:")); + assert!(payload["details"]["conflict"]["suggestedActions"] + .as_array() + .unwrap() + .iter() + .any(|action| action.as_str() == Some("merge"))); let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(markdown.contains("# External")); assert!(!markdown.contains("# Editor")); diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index 6eec7a40..d89d0c73 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -2,14 +2,14 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::documents::{ - DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta, + content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery, }; use axum::extract::{Extension, Json, Query, State}; -use axum::http::{HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1763,10 +1763,10 @@ pub async fn transform_runtime_snapshot( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use tower::util::ServiceExt; fn app() -> axum::Router { diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 4870e548..6c6d5474 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -25,17 +25,17 @@ use crate::workspace_shell::{ use axum::body::Body; use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; -use axum::http::{HeaderName, HeaderValue, Request, StatusCode, Uri, header}; +use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use control_plane::{ - AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, - NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, session_token_hash, + session_token_hash, AppendAuditInput, AuthenticatePasswordInput, CreatePasswordIdentityInput, + NavigationRecentRecord, UpsertNavigationRecentInput, UpsertUserInput, }; use futures_util::{SinkExt, StreamExt}; use leptos::prelude::InnerHtmlAttribute; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::BTreeMap; use std::time::Duration; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; @@ -560,6 +560,7 @@ pub async fn root_entry( active_page_title={active_page_title.clone()} navigation_html={navigation_html.clone().unwrap_or_default()} show_admin_access_policy={show_admin_access_policy} + enable_tree_live={active_source_kind.as_deref() == Some("local_folder")} /> }) }; @@ -622,7 +623,7 @@ pub async fn root_entry( workspace_sidebar_html={workspace_sidebar_html.clone()} page_subtree_json={page_subtree_json} show_admin_access_policy={show_admin_access_policy} - enable_tree_live={active_source_kind.as_deref() != Some("local_folder")} + enable_tree_live={true} /> }); let body_extra = format!( @@ -645,7 +646,7 @@ pub async fn root_entry( let editor_runtime_preload_links = if body_extra.contains("__MNOTE_EDITOR_BOOTSTRAP__") { render_editor_runtime_preload_links() } else { - "" + String::new() }; let mut response = Html(format!( r#" @@ -2448,9 +2449,9 @@ fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> Strin #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; - use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::response::{Html, IntoResponse}; use axum::routing::{get, post}; use control_plane::{UpsertNavigationRecentInput, UpsertUserInput}; @@ -3019,7 +3020,7 @@ mod tests { #[tokio::test] async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() { - use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash}; + use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput}; let app_state = AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -3486,9 +3487,7 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains(r#"data-node-id="local-dir:design~2F05-editor-mainline""#)); - assert!( - html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#) - ); + assert!(html.contains(r#"data-node-id="local-md:design~2F05-editor-mainline~2FTarget.md""#)); assert!( !html.contains(r#"data-node-id="local-md:Home.md""#), "星标 scoped folder 入口的 PageTree 不应回退到 workspace root 页面树" @@ -3672,14 +3671,12 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("mnote-web") ); - assert!( - response - .headers() - .get("content-type") - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .contains("text/html") - ); + assert!(response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .contains("text/html")); } #[tokio::test] @@ -3734,16 +3731,12 @@ mod tests { .map(|value| value.to_str().unwrap_or_default()) .collect::>(); assert!(values.iter().any(|value| value.contains("mnote_session="))); - assert!( - values - .iter() - .any(|value| value.contains("mnote_actor_id=new-user")) - ); - assert!( - values - .iter() - .any(|value| value.contains("mnote_actor_type=user")) - ); + assert!(values + .iter() + .any(|value| value.contains("mnote_actor_id=new-user"))); + assert!(values + .iter() + .any(|value| value.contains("mnote_actor_type=user"))); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); @@ -3822,12 +3815,10 @@ mod tests { .expect("body"); let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["code"], "auth_signup_email_required"); - assert!( - payload["message"] - .as_str() - .unwrap_or_default() - .contains("注册账号时请填写邮箱") - ); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("注册账号时请填写邮箱")); } #[tokio::test] @@ -3851,11 +3842,9 @@ mod tests { .iter() .map(|value| value.to_str().unwrap_or_default()) .collect::>(); - assert!( - values - .iter() - .any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0")) - ); + assert!(values + .iter() + .any(|value| value.contains("mnote_session=") && value.contains("Max-Age=0"))); } #[tokio::test] @@ -3961,16 +3950,12 @@ mod tests { .iter() .map(|value| value.to_str().unwrap_or_default()) .collect::>(); - assert!( - !values - .iter() - .any(|value| value.contains("__convexAuthJWT=")) - ); - assert!( - !values - .iter() - .any(|value| value.contains("__convexAuthRefreshToken=")) - ); + assert!(!values + .iter() + .any(|value| value.contains("__convexAuthJWT="))); + assert!(!values + .iter() + .any(|value| value.contains("__convexAuthRefreshToken="))); assert!(response.headers().get("x-mnote-legacy-upstream").is_none()); } diff --git a/rust/crates/mnote-web/src/routes/health.rs b/rust/crates/mnote-web/src/routes/health.rs index 493e45dd..b26fa25e 100644 --- a/rust/crates/mnote-web/src/routes/health.rs +++ b/rust/crates/mnote-web/src/routes/health.rs @@ -1,7 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; -use axum::Json; use axum::extract::{Extension, State}; +use axum::Json; use serde::Serialize; #[derive(Debug, Serialize)] diff --git a/rust/crates/mnote-web/src/routes/hermes.rs b/rust/crates/mnote-web/src/routes/hermes.rs index f6248a0e..7c13d84d 100644 --- a/rust/crates/mnote-web/src/routes/hermes.rs +++ b/rust/crates/mnote-web/src/routes/hermes.rs @@ -1,15 +1,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use axum::Json; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::{ - RuntimeInput, build_failure_response, build_success_response, execute_runtime_input, - execute_runtime_query, runtime_input_requests_result, + build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, + runtime_input_requests_result, RuntimeInput, }; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner"; @@ -150,10 +150,10 @@ fn stamp_ai_bridge_headers() -> HeaderMap { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -253,12 +253,10 @@ mod tests { assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes"); assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes"); assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge"); - assert!( - payload["eventStreamEndpoint"] - .as_str() - .unwrap_or_default() - .contains("/api/hermes/events/") - ); + assert!(payload["eventStreamEndpoint"] + .as_str() + .unwrap_or_default() + .contains("/api/hermes/events/")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index faa3ff3d..4446672e 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -4,15 +4,15 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::manifest; use crate::transport::convex::{execute_retired_mutation_by_name, execute_retired_query_by_name}; -use axum::Json; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; -use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; +use axum::Json; use control_plane::{AppendAiRuntimeEventInput, UpsertAiRuntimeRunInput}; use futures_util::TryStreamExt; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::env; use std::fs; @@ -1049,7 +1049,7 @@ pub async fn resume_session( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { if is_acp_session_query(&query) { let result = get_acp_session(&state, &context, &session_id, &query).await?; - let mut payload = result.2.0; + let mut payload = result.2 .0; payload["resumed"] = Value::Bool(true); payload["resumeSource"] = payload .get("persistence") @@ -1573,8 +1573,8 @@ pub async fn create_run( Some(®istration.profile), ) .await?; - if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2.0) { - let mut payload = result.2.0; + if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) { + let mut payload = result.2 .0; payload["runtime"] = runtime; Ok((result.0, result.1, Json(payload))) } else { @@ -2230,7 +2230,11 @@ fn profile_home(profile: &str) -> PathBuf { return home; } let candidate = home.join("profiles").join(profile); - if candidate.exists() { candidate } else { home } + if candidate.exists() { + candidate + } else { + home + } } fn profile_config_path(profile: &str) -> PathBuf { @@ -6325,8 +6329,8 @@ fn stamp_client_headers_into(headers: &mut HeaderMap) { #[cfg(test)] mod tests { use super::*; - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::Request; use axum::routing::{get, post}; use control_plane::{DirectoryGrantInput, UpsertAiRuntimeRunInput, UpsertUserInput}; @@ -6379,16 +6383,12 @@ mod tests { let files = changed.as_array().expect("changed files"); assert_eq!(files.len(), 2); - assert!( - files - .iter() - .any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" }) - ); - assert!( - files - .iter() - .any(|file| { file["path"] == "b.md" && file["changeType"] == "added" }) - ); + assert!(files + .iter() + .any(|file| { file["path"] == "a.md" && file["changeType"] == "modified" })); + assert!(files + .iter() + .any(|file| { file["path"] == "b.md" && file["changeType"] == "added" })); assert!(!files.iter().any(|file| { file["path"] .as_str() @@ -6424,11 +6424,9 @@ mod tests { assert!(files.iter().any(|file| { file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified" })); - assert!( - files.iter().any(|file| { - file["path"] == "office/a.docx" && file["changeType"] == "modified" - }) - ); + assert!(files + .iter() + .any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" })); let _ = std::fs::remove_dir_all(&root); } @@ -8469,10 +8467,8 @@ mod tests { let instructions = body["instructions"].as_str().expect("instructions"); assert!(instructions.contains("\"sourceKind\":\"local_folder\"")); assert!(instructions.contains("\"fileReference\"")); - assert!( - instructions - .contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"") - ); + assert!(instructions + .contains("\"rootUri\":\"file:///mnt/Data1T/Mnote_data/users/user_1/我的空间\"")); assert!(instructions.contains("\"aiAccessScope\"")); assert!(instructions.contains("\"allowedRoots\"")); assert!(instructions.contains("\"editorTarget\"")); @@ -8493,10 +8489,8 @@ mod tests { assert!( !instructions.contains("恶意 runTargetSnapshot.editorTarget 正文不应进入 instructions") ); - assert!( - !instructions - .contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions") - ); + assert!(!instructions + .contains("恶意 runTargetSnapshot.workspacePath 正文不应进入 instructions")); assert!(!instructions.contains("\"contextBlocks\"")); assert!(!instructions.contains("\"pageXml\"")); assert!(!instructions.contains("\"pageText\"")); @@ -8595,11 +8589,10 @@ mod tests { env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str), Some("/mnt/Data1T/Mnote_data/users/user_1/我的空间") ); - assert!( - !env.get("MNOTE_AI_ALLOWED_ROOTS_JSON") - .unwrap() - .contains("/mnt/Data1T/Mnote_data/users/user_2") - ); + assert!(!env + .get("MNOTE_AI_ALLOWED_ROOTS_JSON") + .unwrap() + .contains("/mnt/Data1T/Mnote_data/users/user_2")); let scope = serde_json::from_str::( env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"), ) @@ -8669,12 +8662,10 @@ mod tests { payload["allowedRoots"][0]["source"], "sqlite_directory_grant" ); - assert!( - payload["allowedRoots"][0]["grantIds"] - .as_array() - .map(|items| !items.is_empty()) - .unwrap_or(false) - ); + assert!(payload["allowedRoots"][0]["grantIds"] + .as_array() + .map(|items| !items.is_empty()) + .unwrap_or(false)); assert!(!payload.to_string().contains("file:///tmp/evil")); } diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index ebe67b3a..129ab9e8 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -2,11 +2,11 @@ use super::hermes_client; use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::hermes_tools::{ToolCallInput, artifact, block, doc, manifest, page, resource}; -use axum::Json; +use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; -use serde_json::{Value, json}; +use axum::Json; +use serde_json::{json, Value}; use std::collections::HashMap; use std::env; use std::fs::{self, File, OpenOptions}; @@ -827,10 +827,10 @@ fn stamp_tool_headers() -> HeaderMap { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::collections::BTreeMap; use std::fs; use std::sync::Mutex; @@ -1112,56 +1112,36 @@ mod tests { assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.fetch")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.doc.find")); assert!(tools.iter().any(|tool| tool["name"] == "mnote.block.fetch")); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.doc.plan_update") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.block.replace") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.block.insert_after") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.block.delete") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.block.move_after") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.doc.apply_block_ops") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.mindmap.fetch") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.mindmap.apply_ops") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.office.fetch_summary") - ); - assert!( - tools - .iter() - .any(|tool| tool["name"] == "mnote.office.propose_changes") - ); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.doc.plan_update")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.replace")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.insert_after")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.delete")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.block.move_after")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.doc.apply_block_ops")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.mindmap.fetch")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.mindmap.apply_ops")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.office.fetch_summary")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.office.propose_changes")); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") @@ -1209,20 +1189,16 @@ mod tests { } assert!(markdown_edit["inputSchema"]["properties"]["operations"].is_object()); assert!(markdown_edit["inputSchema"]["properties"]["full_content"].is_object()); - assert!( - markdown_edit["inputSchema"]["anyOf"] - .as_array() - .expect("anyOf") - .iter() - .any(|rule| rule["required"] == json!(["operations"])) - ); - assert!( - markdown_edit["inputSchema"]["anyOf"] - .as_array() - .expect("anyOf") - .iter() - .any(|rule| rule["required"] == json!(["full_content"])) - ); + assert!(markdown_edit["inputSchema"]["anyOf"] + .as_array() + .expect("anyOf") + .iter() + .any(|rule| rule["required"] == json!(["operations"]))); + assert!(markdown_edit["inputSchema"]["anyOf"] + .as_array() + .expect("anyOf") + .iter() + .any(|rule| rule["required"] == json!(["full_content"]))); } #[tokio::test] @@ -1252,18 +1228,14 @@ mod tests { .find(|tool| tool["name"] == "mnote.page.save") .expect("page save tool"); - assert!( - markdown_edit["description"] - .as_str() - .expect("description") - .contains("兼容") - ); - assert!( - markdown_edit["description"] - .as_str() - .expect("description") - .contains("agent 原生 patch/diff") - ); + assert!(markdown_edit["description"] + .as_str() + .expect("description") + .contains("兼容")); + assert!(markdown_edit["description"] + .as_str() + .expect("description") + .contains("agent 原生 patch/diff")); assert_eq!( page_save["annotations"]["requiresWritePermission"], Value::Bool(true) @@ -1454,12 +1426,10 @@ mod tests { payload["result"]["objectIdentity"], "resource:mindmap:local-md:README.md:mind_allowed" ); - assert!( - payload["result"]["markdownSummary"] - .as_str() - .unwrap_or_default() - .contains("中心主题") - ); + assert!(payload["result"]["markdownSummary"] + .as_str() + .unwrap_or_default() + .contains("中心主题")); assert_eq!(payload["result"]["nodes"][1]["text"], "分支一"); let _ = fs::remove_dir_all(&root); } @@ -1795,12 +1765,10 @@ mod tests { assert_eq!(payload["toolName"], "mnote.page.get"); assert_eq!(payload["toolCallId"], "call_1"); assert_eq!(payload["result"]["title"], "服务端页面"); - assert!( - payload["result"]["bodySummary"] - .as_str() - .unwrap_or_default() - .contains("章节一") - ); + assert!(payload["result"]["bodySummary"] + .as_str() + .unwrap_or_default() + .contains("章节一")); assert_eq!(payload["audit"]["effect"], "read"); } @@ -1846,12 +1814,10 @@ mod tests { json!("heading_1") ); assert_eq!(payload["result"]["blocks"][0]["text"], json!("章节一")); - assert!( - payload["result"]["blocks"][0]["revisionRef"] - .as_str() - .unwrap_or_default() - .starts_with("pageRev:7:block:heading_1:hash:") - ); + assert!(payload["result"]["blocks"][0]["revisionRef"] + .as_str() + .unwrap_or_default() + .starts_with("pageRev:7:block:heading_1:hash:")); } #[tokio::test] @@ -1978,12 +1944,10 @@ mod tests { assert_eq!(payload["result"]["scope"], "selection"); assert_eq!(payload["result"]["format"], "page_xml"); assert_eq!(payload["result"]["blocks"].as_array().unwrap().len(), 1); - assert!( - payload["result"]["content"] - .as_str() - .unwrap_or_default() - .contains("> + Send>>; @@ -70,7 +70,12 @@ async fn build_document_events_stream( .document_id .as_deref() .and_then(local_markdown_relative_path_from_document_id) - .or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path)); + .or_else(|| { + query + .resource_path + .as_deref() + .and_then(normalize_resource_event_path) + }); let subscription = state .local_folder_watcher_registry() .subscribe(&canonical_root) @@ -147,26 +152,15 @@ async fn build_tree_live_stream( &file_tree_snapshot, ); - let subscription = match state + let subscription = state .local_folder_watcher_registry() .subscribe(&canonical_root) - { - Ok(subscription) => subscription, - Err(error) => { - tracing::warn!( - error = %error, - root_uri = %root_uri, - "local_folder tree live watcher unavailable; falling back to revision polling" - ); - let stream = build_tree_live_polling_stream( - root_uri, - workspace_id, - revision.revision, - initial_payload, - ); - return Ok((HeaderMap::new(), stream)); - } - }; + .map_err(|error| { + WebError::internal(format!( + "local_folder tree live watcher unavailable: {error}" + )) + .with_context(&context) + })?; let stream = stream::unfold( (Some(initial_payload), subscription, root_uri, workspace_id), @@ -224,81 +218,6 @@ async fn build_tree_live_stream( Ok((HeaderMap::new(), stream)) } -fn build_tree_live_polling_stream( - root_uri: String, - workspace_id: String, - initial_revision: String, - initial_payload: Value, -) -> BoxedEventStream { - let mut poll_interval = interval(Duration::from_millis(1_200)); - poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - - stream::unfold( - Some(TreeLivePollingState { - root_uri, - workspace_id, - current_revision: initial_revision, - initial_payload: Some(initial_payload), - poll_interval, - }), - |state| async move { - let mut state = state?; - if let Some(payload) = state.initial_payload.take() { - return Some((Ok(stream_event("snapshot", &payload)), Some(state))); - } - - loop { - state.poll_interval.tick().await; - let Some((next_revision, resync_payload)) = tree_live_polling_resync_payload( - &state.root_uri, - &state.workspace_id, - &state.current_revision, - ) else { - continue; - }; - state.current_revision = next_revision; - return Some((Ok(stream_event("resync", &resync_payload)), Some(state))); - } - }, - ) - .boxed() -} - -struct TreeLivePollingState { - root_uri: String, - workspace_id: String, - current_revision: String, - initial_payload: Option, - poll_interval: tokio::time::Interval, -} - -fn tree_live_polling_resync_payload( - root_uri: &str, - workspace_id: &str, - current_revision: &str, -) -> Option<(String, Value)> { - let next_revision = local_folder_watch_revision(root_uri).ok()?; - if next_revision.revision == current_revision { - return None; - } - let resync_payload = rebuild_tree_resync_payload(root_uri, workspace_id)?; - Some((next_revision.revision, resync_payload)) -} - -fn rebuild_tree_resync_payload(root_uri: &str, workspace_id: &str) -> Option { - let revision = local_folder_watch_revision(root_uri).ok()?; - let sidebar_snapshot = load_local_folder_page_tree_snapshot(root_uri).ok()?; - let file_tree_snapshot = load_local_folder_file_tree_snapshot(root_uri).ok()?; - Some(build_tree_snapshot_payload( - root_uri, - workspace_id, - &revision.revision, - "resync", - &sidebar_snapshot, - &file_tree_snapshot, - )) -} - fn parent_relative_path_for_watch_path(relative_path: &str) -> String { let normalized = relative_path.trim().trim_matches('/').replace('\\', "/"); if normalized.is_empty() || normalized == "." { @@ -472,7 +391,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent { #[cfg(test)] mod tests { use super::*; - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use crate::routes::local_folder_source::initialize_local_workspace_for_actor; use axum::body::Body; use axum::http::Request; @@ -614,7 +533,10 @@ mod tests { }); assert!(document_event_targets_relative_path(&payload, &expected)); - assert!(!document_event_targets_relative_path(&other_payload, &expected)); + assert!(!document_event_targets_relative_path( + &other_payload, + &expected + )); assert!( normalize_resource_event_path("../escape.pdf").is_none(), "resource watch path 不能越过 root" @@ -669,40 +591,6 @@ mod tests { assert!(payload["data"]["tree"].is_object()); } - #[test] - fn tree_live_polling_resync_payload_tracks_revision_changes() { - let root = test_root("tree-live-polling-resync"); - std::fs::write(root.join("README.md"), "# Initial\n").expect("write initial"); - let root_uri = format!("file://{}", root.display()); - let workspace_id = - local_workspace_id_from_root_uri(&root_uri).expect("resolve local workspace id"); - let initial_revision = local_folder_watch_revision(&root_uri) - .expect("initial revision") - .revision; - - assert!( - tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision).is_none(), - "revision 未变化时不应发送 resync" - ); - - std::fs::create_dir_all(root.join("docs")).expect("create docs"); - std::fs::write(root.join("docs/new.md"), "# New\n").expect("write new markdown"); - let (next_revision, payload) = - tree_live_polling_resync_payload(&root_uri, &workspace_id, &initial_revision) - .expect("revision change should build resync payload"); - - assert_ne!(next_revision, initial_revision); - assert_eq!(payload["kind"], "resync"); - assert_eq!(payload["sourceKind"], "local_folder"); - assert_eq!(payload["rootUri"], root_uri); - assert_eq!(payload["workspaceId"], workspace_id); - assert_eq!(payload["revision"], next_revision); - assert!(payload["data"]["dataset"]["kernel_sidebar_projection"].is_object()); - assert!(payload["data"]["dataset"]["kernel_file_tree_projection"].is_object()); - - let _ = std::fs::remove_dir_all(root); - } - #[test] fn local_folder_watch_batch_payload_declares_changed_paths_and_parents() { let root = test_root("tree-live-watch-batch"); @@ -741,13 +629,11 @@ mod tests { && parent["reason"].as_str() == Some("child-watch")), "watch batch 应声明 docs affected parent: {payload}" ); - assert!( - payload["eventKinds"] - .as_array() - .expect("event kinds") - .iter() - .any(|kind| kind.as_str() == Some("Modify(Data)")) - ); + assert!(payload["eventKinds"] + .as_array() + .expect("event kinds") + .iter() + .any(|kind| kind.as_str() == Some("Modify(Data)"))); let _ = std::fs::remove_dir_all(root); } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 4ffe8dcc..4a3352e5 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -6,13 +6,13 @@ use crate::page_aggregate::{ PagePermissions, PageStats, PageTree, }; use crate::routes::local_markdown_parser::{ - file_stem_title, parse_markdown_page, split_frontmatter, + file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter, }; use crate::routes::local_search_index; use crate::routes::snapshot_support::ProjectionSnapshot; -use axum::Json; use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::project_legacy_content_to_block_document; use control_plane::AppendAuditInput; use control_plane::{ @@ -24,7 +24,7 @@ use core_protocol::{ }; use reqwest::Url; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; +use serde_json::{json, Map, Value}; use std::cmp::Ordering; use std::collections::hash_map::DefaultHasher; use std::collections::{BTreeMap, BTreeSet}; @@ -55,8 +55,8 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock< Mutex>, > = OnceLock::new(); -fn local_page_tree_snapshot_cache() --> &'static Mutex> { +fn local_page_tree_snapshot_cache( +) -> &'static Mutex> { LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) } @@ -2966,6 +2966,7 @@ pub fn resolve_local_markdown_page_aggregate( "本地 rootUri 必须指向目录", )); } + let root_source_uri = file_uri_for_path(&canonical_root); let metadata = load_local_folder_metadata(&canonical_root)?; let markdown_file = find_markdown_by_page_id(&canonical_root, &metadata, document_id)? .ok_or_else(|| { @@ -2995,6 +2996,20 @@ pub fn resolve_local_markdown_page_aggregate( &parsed.body, &attachment_paths, ); + let attachment_refs = parse_markdown_attachment_refs( + &parsed.body, + &markdown_file.path.display().to_string(), + &root_source_uri, + ) + .into_iter() + .map(|mut attachment_ref| { + if let Some(path) = attachment_ref.resolved_absolute_path.as_deref() { + let resolved_path = Path::new(path); + attachment_ref.authorized = Some(resolved_path.starts_with(&canonical_root)); + } + attachment_ref + }) + .collect::>(); let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; let page_subtree = markdown_page_subtree(document_id, &title, &content); let workspace_id = local_workspace_id(&canonical_root); @@ -3063,6 +3078,7 @@ pub fn resolve_local_markdown_page_aggregate( block_document, block_projection_version: 1, projection_source: "local_markdown.content".into(), + attachment_refs: serde_json::to_value(attachment_refs).unwrap_or_else(|_| json!([])), }, tree: PageTree { page_subtree }, stats: PageStats { @@ -3927,14 +3943,7 @@ pub(crate) fn write_local_markdown_asset( )); } - let page_resource_dir = - markdown_page_resource_directory(&markdown_file.path).ok_or_else(|| { - WebError::bad_request_code( - "local_asset_upload_bad_markdown_path", - "无法解析本地页面资源目录", - ) - })?; - let asset_dir = page_resource_dir; + let asset_dir = markdown_dir.to_path_buf(); if !asset_dir.starts_with(&canonical_root) { return Err(WebError::bad_request_code( "local_folder_root_escape", @@ -3965,6 +3974,24 @@ pub(crate) fn write_local_markdown_asset( let root_relative_path = normalize_relative_path(&canonical_root, &target)?; let markdown_relative_path = normalize_markdown_relative_asset_path(markdown_dir, &target)?; + let markdown_href = markdown_href_for_relative_path(&markdown_relative_path); + let mut attachment_ref = parse_markdown_attachment_refs( + &format!( + "[{}]({})", + target + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&sanitized_name), + markdown_href + ), + &markdown_file.path.display().to_string(), + &file_uri_for_path(&canonical_root), + ) + .into_iter() + .next(); + if let Some(ref mut value) = attachment_ref { + value.authorized = Some(true); + } let asset_type = local_upload_asset_type(kind, &file.content_type); let mut uploaded_assets = metadata.uploaded_assets; uploaded_assets.insert( @@ -3997,6 +4024,8 @@ pub(crate) fn write_local_markdown_asset( "rootUri": file_uri_for_path(&canonical_root), "rootRelativePath": root_relative_path, "markdownRelativePath": markdown_relative_path, + "markdownHref": markdown_href, + "attachmentRef": attachment_ref, })) } @@ -7063,7 +7092,11 @@ fn parent_key_for_relative_path(relative_path: &str) -> String { .map(|component| component.as_os_str().to_string_lossy().to_string()) .collect::>() .join("/"); - if value.is_empty() { None } else { Some(value) } + if value.is_empty() { + None + } else { + Some(value) + } }) .map(|value| normalize_file_order_parent_key(&value)) .unwrap_or_else(|| ".".to_string()) @@ -8130,6 +8163,15 @@ fn normalize_markdown_relative_asset_path( .join("/")) } +fn markdown_href_for_relative_path(relative_path: &str) -> String { + let trimmed = relative_path.trim().replace('\\', "/"); + if trimmed.starts_with("./") || trimmed.starts_with("../") { + trimmed + } else { + format!("./{trimmed}") + } +} + fn local_upload_asset_type(kind: &str, mime_type: &str) -> &'static str { let normalized_kind = kind.trim().to_ascii_lowercase(); if normalized_kind == "image" || mime_type.trim().to_ascii_lowercase().starts_with("image/") { @@ -8384,6 +8426,7 @@ fn editor_blocks_to_markdown_with_rewrite( lines.push(text); } else { let src = rewrite_local_open_url_to_markdown_relative(src, local_file_context) + .map(|value| markdown_href_for_relative_path(&value)) .unwrap_or_else(|| src.to_string()); lines.push(format!( "![{}]({})", @@ -8412,9 +8455,7 @@ fn editor_blocks_to_markdown_with_rewrite( if url.is_empty() { lines.push(text); } else { - let url = rewrite_local_open_url_to_markdown_relative(url, local_file_context) - .unwrap_or_else(|| url.to_string()); - let label = if name.is_empty() { url.as_str() } else { name }; + let label = if name.is_empty() { url } else { name }; lines.push(format!("[{}]({})", label, markdown_link_target(&url))); } } @@ -9041,7 +9082,7 @@ fn inline_styles_from_object(object: &Map) -> Value { fn markdown_text_with_styles( text: &str, styles: &Value, - local_file_context: Option<(&Path, &Path)>, + _local_file_context: Option<(&Path, &Path)>, ) -> String { let mut value = escape_markdown_inline_text(text); let link = styles @@ -9078,8 +9119,6 @@ fn markdown_text_with_styles( value = format!("~~{value}~~"); } if let Some(href) = link { - let href = - rewrite_local_open_url_to_markdown_relative(&href, local_file_context).unwrap_or(href); value = format!("[{}]({href})", value.replace(']', r"\]")); } value @@ -9224,10 +9263,6 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val #[cfg(test)] mod tests { use super::{ - LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery, - LocalResourceReadQuery, LocalResourceWriteRequest, LocalShareGrantRequest, - LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, SharedCacheRecordRequest, - SyncConflictReportRequest, SyncPendingChangeRequest, add_sqlite_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base, create_local_access_grant, create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant, @@ -9246,15 +9281,18 @@ mod tests { resolve_local_markdown_page_aggregate, save_local_markdown_page, update_local_markdown_title, validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data, write_local_resource, - write_sync_conflict_report, + write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest, + LocalFileOpenQuery, LocalResourceReadQuery, LocalResourceWriteRequest, + LocalShareGrantRequest, LocalShareLinkRequest, LocalUploadFile, ShareLinkListQuery, + SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest, }; use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; - use axum::Json; use axum::extract::{Extension, Path as AxumPath, Query, State}; use axum::http::{HeaderMap, Method, StatusCode}; + use axum::Json; use control_plane::{DirectoryGrantInput, UpsertUserInput}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::sync::Mutex; fn env_lock() -> &'static Mutex<()> { @@ -9526,25 +9564,28 @@ mod tests { "tableCell" ); assert_eq!( - table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0]["text"], + table["props"]["tiptapTable"]["content"][0]["content"][0]["content"][0]["content"][0] + ["text"], "左" ); assert_eq!( - table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["text"], + table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] + ["text"], "A" ); assert_eq!( - table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0]["marks"] - [0]["type"], + table["props"]["tiptapTable"]["content"][1]["content"][0]["content"][0]["content"][0] + ["marks"][0]["type"], "code" ); assert_eq!( - table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["text"], + table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] + ["text"], "B" ); assert_eq!( - table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0]["marks"] - [0]["type"], + table["props"]["tiptapTable"]["content"][1]["content"][1]["content"][0]["content"][0] + ["marks"][0]["type"], "bold" ); } @@ -9647,12 +9688,10 @@ mod tests { let body = serde_json::to_value(&aggregate.body).expect("body json"); assert_eq!(body["fileVersion"], body["conflictDetectionKey"]); - assert!( - body["fileVersion"] - .as_str() - .expect("file version") - .starts_with("local-md:local-md:README.md:") - ); + assert!(body["fileVersion"] + .as_str() + .expect("file version") + .starts_with("local-md:local-md:README.md:")); let _ = std::fs::remove_dir_all(&root); } @@ -9674,12 +9713,10 @@ mod tests { .expect("save"); assert_eq!(result["fileVersion"], result["conflict_detection_key"]); - assert!( - result["fileVersion"] - .as_str() - .expect("file version") - .starts_with("local-md:local-md:README.md:") - ); + assert!(result["fileVersion"] + .as_str() + .expect("file version") + .starts_with("local-md:local-md:README.md:")); let _ = std::fs::remove_dir_all(&root); } @@ -9842,7 +9879,7 @@ fn main() {} } #[test] - fn local_markdown_save_rewrites_uploaded_markdown_inline_link_as_media_path() { + fn local_markdown_save_does_not_migrate_runtime_open_url_inline_link() { let root = temp_root("mnote-local-uploaded-md-inline-link"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); @@ -9888,28 +9925,18 @@ fn main() {} ) .expect("save inline attachment link"); - assert_eq!(asset["sourcePath"], "notes.md"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); - assert!(saved.contains("[notes.md](notes.md)")); - assert!(!saved.contains("/api/local-folder/files/open")); - - let aggregate = - resolve_local_markdown_page_aggregate(&root_uri, document_id).expect("aggregate"); - let media = aggregate - .body - .content - .as_array() - .expect("blocks") - .iter() - .find(|block| block["type"].as_str() == Some("media")) - .expect("uploaded md inline link should reload as media block"); - assert_eq!(media["props"]["sourcePath"], "notes.md"); + assert_eq!(asset["sourcePath"], "notes.md"); + assert!( + saved.contains("/api/local-folder/files/open"), + "开发态不再把旧 runtime open URL 自动迁移为标准 href" + ); let _ = std::fs::remove_dir_all(&root); } #[test] - fn local_markdown_save_rewrites_uploaded_markdown_relative_open_url_as_media_path() { + fn local_markdown_save_does_not_migrate_relative_runtime_open_url() { let root = temp_root("mnote-local-uploaded-md-relative-open-link"); init_workspace(&root); std::fs::create_dir_all(root.join("Page")).expect("create page dir"); @@ -9956,7 +9983,10 @@ fn main() {} .expect("save relative open url"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); - assert!(saved.contains("[notes.md](notes.md)")); + assert!( + saved.contains("/api/local-folder/files/open"), + "开发态不再把旧 runtime open URL 自动迁移为标准 href" + ); let _ = std::fs::remove_dir_all(&root); } @@ -10800,12 +10830,10 @@ fn main() {} assert_eq!(created["grant"]["ownerUserId"], "user_owner"); assert_eq!(created["grant"]["targetUserId"], "user_target"); assert_eq!(created["grant"]["permission"], "write"); - assert!( - created["grant"]["shareId"] - .as_str() - .unwrap_or_default() - .starts_with("folder_") - ); + assert!(created["grant"]["shareId"] + .as_str() + .unwrap_or_default() + .starts_with("folder_")); let outside_error = create_user_share_grant( Extension(request_context("user_owner", "user")), @@ -11075,13 +11103,11 @@ fn main() {} assert_eq!(stored.len(), 1); assert_eq!(stored[0].id, link_id); assert_ne!(stored[0].token_hash, "visible-token"); - assert!( - state - .control_plane() - .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) - .expect("resolve share link") - .is_some() - ); + assert!(state + .control_plane() + .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) + .expect("resolve share link") + .is_some()); let (_, Json(listed)) = get_share_links( State(state.clone()), @@ -11114,13 +11140,11 @@ fn main() {} .expect("share revoked broadcast delta"); assert_eq!(revoked_delta["kind"], "control_plane_event"); assert_eq!(revoked_delta["eventType"], "control.share.revoked"); - assert!( - state - .control_plane() - .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) - .expect("resolve revoked share link") - .is_none() - ); + assert!(state + .control_plane() + .resolve_share_link(&control_plane::share_token_hash_v1("visible-token")) + .expect("resolve revoked share link") + .is_none()); assert_eq!( state .control_plane() @@ -11324,13 +11348,11 @@ fn main() {} payload["workspace"]["manifest"]["ownerId"], "user@example.com" ); - assert!( - payload["workspace"]["manifest"]["capabilities"] - .as_array() - .expect("capabilities") - .iter() - .any(|value| value.as_str() == Some("markdown_edit")) - ); + assert!(payload["workspace"]["manifest"]["capabilities"] + .as_array() + .expect("capabilities") + .iter() + .any(|value| value.as_str() == Some("markdown_edit"))); ensure_local_workspace_access_for_actor("user@example.com", "user", &root_uri) .expect("owner can access managed workspace"); @@ -11581,12 +11603,11 @@ fn main() {} execute_local_tree_command(&root_uri, "delete", "local-md:Renamed.md", None, None) .expect("delete loose markdown"); assert!(!root.join("Renamed.md").exists()); - assert!( - root.join(".mnote") - .join("trash") - .join("Renamed.md") - .is_file() - ); + assert!(root + .join(".mnote") + .join("trash") + .join("Renamed.md") + .is_file()); assert_eq!(deleted["resourceKind"].as_str(), Some("markdown")); let restored = @@ -13098,6 +13119,11 @@ fn main() {} assert_eq!(asset["uploadIntent"], "editor.markdown.attach"); assert_eq!(asset["rootRelativePath"], "docs/README/photo-1.png"); assert_eq!(asset["markdownRelativePath"], "photo-1.png"); + assert_eq!(asset["markdownHref"], "./photo-1.png"); + assert_eq!(asset["attachmentRef"]["rawHref"], "./photo-1.png"); + assert_eq!(asset["attachmentRef"]["kind"], "pageLocal"); + assert_eq!(asset["attachmentRef"]["openKind"], "image"); + assert_eq!(asset["attachmentRef"]["authorized"], true); assert_eq!( asset["ownerDocumentId"], "local-md:docs~2FREADME~2FREADME.md" @@ -13122,6 +13148,8 @@ fn main() {} assert_eq!(markdown_asset["uploadIntent"], "editor.markdown.attach"); assert_eq!(markdown_asset["rootRelativePath"], "docs/README/notes.md"); assert_eq!(markdown_asset["markdownRelativePath"], "notes.md"); + assert_eq!(markdown_asset["markdownHref"], "./notes.md"); + assert_eq!(markdown_asset["attachmentRef"]["openKind"], "text"); assert_eq!( markdown_asset["ownerDocumentId"], "local-md:docs~2FREADME~2FREADME.md" @@ -13155,6 +13183,31 @@ fn main() {} .expect("uploaded asset index"); assert!(uploaded_asset_index.contains("docs/README/notes.md")); + std::fs::write(root.join("docs").join("Loose.md"), "# Loose\n").expect("write loose md"); + let loose_asset = write_local_markdown_asset( + &root_uri, + "local-md:docs~2FLoose.md", + "attachment", + LocalUploadFile { + name: "loose.pdf".to_string(), + content_type: "application/pdf".to_string(), + bytes: b"%PDF-1.4\n".to_vec(), + }, + ) + .expect("upload loose markdown asset"); + assert_eq!(loose_asset["sourcePath"], "loose.pdf"); + assert_eq!(loose_asset["rootRelativePath"], "docs/loose.pdf"); + assert_eq!(loose_asset["markdownRelativePath"], "loose.pdf"); + assert_eq!(loose_asset["markdownHref"], "./loose.pdf"); + assert!( + root.join("docs").join("loose.pdf").is_file(), + "非 bundle Markdown 上传应写入 md 同目录" + ); + assert!( + !root.join("docs").join("Loose").join("loose.pdf").exists(), + "非 bundle Markdown 上传不应写入同名子目录" + ); + let snapshot = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/README") .expect("file tree"); let items = snapshot.projection["items"].as_array().expect("items"); diff --git a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs index 04937e90..7d50b83b 100644 --- a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs +++ b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs @@ -1,7 +1,10 @@ use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment}; -use comrak::{Arena, Options, parse_document}; -use serde_json::{Map, Value, json}; +use comrak::{parse_document, Arena, Options}; +use serde::Serialize; +use serde_json::{json, Map, Value}; use std::collections::BTreeSet; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct ParsedLocalMarkdownPage { @@ -78,6 +81,34 @@ struct MarkdownInlineStyles { link: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentSourceRange { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentRef { + pub ref_id: String, + pub owner_document_path: String, + pub owner_root_uri: String, + pub raw_href: String, + pub normalized_href: String, + pub label: String, + pub kind: String, + pub resolved_uri: Option, + pub resolved_absolute_path: Option, + pub relative_path: Option, + pub ext: Option, + pub content_type: Option, + pub exists: Option, + pub authorized: Option, + pub open_kind: String, + pub source_range: Option, +} + pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage { let (_frontmatter, body) = split_frontmatter(markdown); let body_owned = body.to_string(); @@ -101,6 +132,28 @@ pub fn markdown_to_blocks_with_attachment_paths( markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths)) } +pub fn parse_markdown_attachment_refs( + markdown: &str, + owner_document_path: &str, + owner_root_uri: &str, +) -> Vec { + let arena = Arena::new(); + let options = markdown_options(); + let root = parse_document(&arena, markdown, &options); + let mut refs = Vec::new(); + let mut cursor = 0usize; + collect_attachment_refs_from_ast( + root, + markdown, + owner_document_path, + owner_root_uri, + &mut cursor, + &mut refs, + ); + collect_html_embed_attachment_refs(markdown, owner_document_path, owner_root_uri, &mut refs); + refs +} + fn markdown_options() -> Options<'static> { let mut options = Options::default(); options.extension.table = true; @@ -159,6 +212,390 @@ fn parse_markdown_attachment_link_with_paths( Some((name.to_string(), target.to_string())) } +fn collect_attachment_refs_from_ast<'a>( + node: &'a AstNode<'a>, + markdown: &str, + owner_document_path: &str, + owner_root_uri: &str, + cursor: &mut usize, + refs: &mut Vec, +) { + match &node.data.borrow().value { + NodeValue::Link(link) => { + let raw_href = link.url.trim(); + if should_collect_attachment_href(raw_href) { + let label = collect_plain_text(node); + let source_range = find_href_source_range(markdown, raw_href, cursor); + refs.push(build_attachment_ref( + owner_document_path, + owner_root_uri, + raw_href, + label.trim(), + source_range, + )); + } + } + NodeValue::Image(link) => { + let raw_href = link.url.trim(); + if should_collect_attachment_href(raw_href) { + let label = collect_plain_text(node); + let source_range = find_href_source_range(markdown, raw_href, cursor); + refs.push(build_attachment_ref( + owner_document_path, + owner_root_uri, + raw_href, + label.trim(), + source_range, + )); + } + } + _ => {} + } + for child in node.children() { + collect_attachment_refs_from_ast( + child, + markdown, + owner_document_path, + owner_root_uri, + cursor, + refs, + ); + } +} + +fn collect_html_embed_attachment_refs( + markdown: &str, + owner_document_path: &str, + owner_root_uri: &str, + refs: &mut Vec, +) { + let lower = markdown.to_ascii_lowercase(); + let mut offset = 0usize; + while let Some(relative_start) = lower[offset..].find("') else { + break; + }; + let end = start + relative_end + 1; + let fragment = &markdown[start..end]; + if let Some((raw_href, value_start, value_end)) = + extract_html_attr(fragment, "src").or_else(|| extract_html_attr(fragment, "href")) + { + let source_range = Some(AttachmentSourceRange { + start: start + value_start, + end: start + value_end, + }); + refs.push(build_attachment_ref( + owner_document_path, + owner_root_uri, + raw_href, + raw_href, + source_range, + )); + } + offset = end; + } +} + +fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usize, usize)> { + let lower = fragment.to_ascii_lowercase(); + let needle = format!("{name}="); + let attr_start = lower.find(&needle)? + needle.len(); + let bytes = fragment.as_bytes(); + let quote = *bytes.get(attr_start)?; + if quote != b'"' && quote != b'\'' { + return None; + } + let value_start = attr_start + 1; + let value_end = fragment[value_start..] + .find(quote as char) + .map(|index| value_start + index)?; + Some((&fragment[value_start..value_end], value_start, value_end)) +} + +fn should_collect_attachment_href(raw_href: &str) -> bool { + let trimmed = raw_href.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:") +} + +fn build_attachment_ref( + owner_document_path: &str, + owner_root_uri: &str, + raw_href: &str, + label: &str, + source_range: Option, +) -> AttachmentRef { + let raw_href = raw_href.trim(); + let normalized_href = normalize_attachment_href(raw_href); + let owner_document = Path::new(owner_document_path); + let owner_dir = owner_document.parent(); + let (kind, resolved_absolute_path, relative_path) = + resolve_attachment_path(&normalized_href, owner_dir, owner_root_uri); + let resolved_uri = resolved_absolute_path + .as_ref() + .map(|path| file_uri_for_attachment_path(Path::new(path))) + .or_else(|| { + if is_remote_href(&normalized_href) { + Some(normalized_href.clone()) + } else { + None + } + }); + let ext = attachment_extension(&normalized_href, resolved_absolute_path.as_deref()); + let content_type = ext + .as_deref() + .and_then(attachment_content_type) + .map(str::to_string); + let exists = resolved_absolute_path + .as_ref() + .map(|path| Path::new(path).exists()); + let open_kind = attachment_open_kind(ext.as_deref()).to_string(); + let label = if label.trim().is_empty() { + resolved_absolute_path + .as_ref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .or_else(|| { + Path::new(&normalized_href) + .file_name() + .and_then(|name| name.to_str()) + }) + .unwrap_or(raw_href) + .to_string() + } else { + label.trim().to_string() + }; + AttachmentRef { + ref_id: attachment_ref_id(owner_document_path, raw_href, &source_range), + owner_document_path: owner_document_path.to_string(), + owner_root_uri: owner_root_uri.to_string(), + raw_href: raw_href.to_string(), + normalized_href, + label, + kind, + resolved_uri, + resolved_absolute_path, + relative_path, + ext, + content_type, + exists, + authorized: None, + open_kind, + source_range, + } +} + +fn resolve_attachment_path( + href: &str, + owner_dir: Option<&Path>, + owner_root_uri: &str, +) -> (String, Option, Option) { + if is_remote_href(href) { + return ("remoteUrl".to_string(), None, None); + } + if let Some(path) = href.strip_prefix("file://").and_then(file_uri_path) { + return ( + "externalFile".to_string(), + Some(normalize_path_string(&path)), + None, + ); + } + let href_path = Path::new(href); + if href_path.is_absolute() { + return ( + "externalFile".to_string(), + Some(normalize_path_string(href_path)), + None, + ); + } + let decoded_href = percent_decode_lossy(href); + if decoded_href.starts_with("../") || decoded_href.contains("/../") { + return ("unknown".to_string(), None, None); + } + let relative_path = decoded_href + .strip_prefix("./") + .unwrap_or(&decoded_href) + .replace('\\', "/"); + let resolved = owner_dir.map(|dir| normalize_path_string(&dir.join(&decoded_href))); + let root_path = owner_root_path(owner_root_uri); + let relative_to_root = resolved + .as_deref() + .and_then(|path| { + root_path + .as_ref() + .and_then(|root| relative_to_root_path(path, root)) + }) + .or(Some(relative_path)); + ("pageLocal".to_string(), resolved, relative_to_root) +} + +fn normalize_attachment_href(raw_href: &str) -> String { + let trimmed = raw_href + .trim() + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(raw_href.trim()) + .trim(); + if Path::new(trimmed).is_absolute() { + file_uri_for_attachment_path(Path::new(trimmed)) + } else { + trimmed.to_string() + } +} + +fn file_uri_path(value: &str) -> Option { + let path = if let Some(rest) = value.strip_prefix("localhost/") { + format!("/{rest}") + } else if value.starts_with('/') { + value.to_string() + } else { + format!("/{value}") + }; + Some(PathBuf::from(percent_decode_lossy(&path))) +} + +fn owner_root_path(root_uri: &str) -> Option { + root_uri + .strip_prefix("file://") + .and_then(file_uri_path) + .or_else(|| { + let path = Path::new(root_uri); + if path.is_absolute() { + Some(path.to_path_buf()) + } else { + None + } + }) +} + +fn relative_to_root_path(path: &str, root: &Path) -> Option { + Path::new(path) + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/")) +} + +fn find_href_source_range( + markdown: &str, + raw_href: &str, + cursor: &mut usize, +) -> Option { + let raw_href = raw_href.trim(); + let start = markdown + .get(*cursor..) + .and_then(|tail| tail.find(raw_href).map(|index| *cursor + index)) + .or_else(|| markdown.find(raw_href))?; + let end = start + raw_href.len(); + *cursor = end; + Some(AttachmentSourceRange { start, end }) +} + +fn is_remote_href(href: &str) -> bool { + href.starts_with("http://") || href.starts_with("https://") +} + +fn attachment_extension(href: &str, path: Option<&str>) -> Option { + path.or(Some(href)) + .and_then(|value| Path::new(value).extension()) + .and_then(|value| value.to_str()) + .map(|value| value.trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn attachment_content_type(ext: &str) -> Option<&'static str> { + match ext { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "svg" => Some("image/svg+xml"), + "pdf" => Some("application/pdf"), + "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"), + "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + "mp3" => Some("audio/mpeg"), + "wav" => Some("audio/wav"), + "mp4" => Some("video/mp4"), + "webm" => Some("video/webm"), + "md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml" + | "yaml" | "yml" => Some("text/plain"), + _ => None, + } +} + +fn attachment_open_kind(ext: Option<&str>) -> &'static str { + match ext.unwrap_or_default() { + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => "image", + "pdf" => "pdf", + "doc" | "docx" | "ppt" | "pptx" | "xls" | "xlsx" => "office", + "mp3" | "wav" | "ogg" | "m4a" => "audio", + "mp4" | "webm" | "mov" | "mkv" => "video", + "md" | "markdown" | "txt" | "rs" | "js" | "ts" | "tsx" | "jsx" | "json" | "toml" + | "yaml" | "yml" => "text", + "" => "unknown", + _ => "download", + } +} + +fn normalize_path_string(path: &Path) -> String { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized.to_string_lossy().replace('\\', "/") +} + +fn file_uri_for_attachment_path(path: &Path) -> String { + format!("file://{}", normalize_path_string(path)) +} + +fn percent_decode_lossy(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut index = 0usize; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + output.push(high * 16 + low); + index += 3; + continue; + } + } + output.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&output).into_owned() +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn attachment_ref_id( + owner_document_path: &str, + raw_href: &str, + source_range: &Option, +) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + owner_document_path.hash(&mut hasher); + raw_href.hash(&mut hasher); + source_range.hash(&mut hasher); + format!("attachment:{:016x}", hasher.finish()) +} + fn parse_markdown_ast_document( markdown: &str, attachment_paths: &BTreeSet, @@ -787,7 +1224,7 @@ pub(crate) fn file_stem_title(file_name: &str) -> String { #[cfg(test)] mod tests { - use super::{markdown_to_blocks, parse_markdown_page}; + use super::{markdown_to_blocks, parse_markdown_attachment_refs, parse_markdown_page}; #[test] fn markdown_image_parses_as_image_block() { @@ -802,6 +1239,94 @@ mod tests { assert_eq!(first["props"]["alt"].as_str(), Some("示例图片")); } + #[test] + fn markdown_attachment_refs_parse_standard_href_variants() { + let root = std::env::temp_dir().join(format!( + "mnote-attachment-ref-parser-{}", + std::process::id() + )); + let owner_dir = root.join("docs"); + std::fs::create_dir_all(&owner_dir).expect("create owner dir"); + std::fs::write(owner_dir.join("同目录 文件.pdf"), b"pdf").expect("write relative file"); + std::fs::write(owner_dir.join("figure.png"), b"png").expect("write image file"); + let owner = owner_dir.join("page.md"); + let external = root.join("external.docx"); + std::fs::write(&external, b"docx").expect("write external file"); + let markdown = format!( + "[同目录 PDF](./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf)\n\ +![图片](./figure.png)\n\ +[外部](file://{})\n\ +[网页资料](https://example.com/paper.pdf)\n\ +[裸绝对]({})\n\ +\n", + external.display(), + external.display() + ); + + let refs = parse_markdown_attachment_refs( + &markdown, + &owner.display().to_string(), + &format!("file://{}", root.display()), + ); + + assert_eq!(refs.len(), 6); + assert_eq!( + refs[0].raw_href, + "./%E5%90%8C%E7%9B%AE%E5%BD%95%20%E6%96%87%E4%BB%B6.pdf" + ); + assert_eq!(refs[0].kind, "pageLocal"); + assert_eq!(refs[0].open_kind, "pdf"); + assert_eq!(refs[0].exists, Some(true)); + assert_eq!( + refs[0].relative_path, + Some("docs/同目录 文件.pdf".to_string()) + ); + assert_eq!(refs[1].open_kind, "image"); + assert_eq!(refs[2].kind, "externalFile"); + assert_eq!(refs[2].open_kind, "office"); + assert_eq!(refs[3].kind, "remoteUrl"); + assert_eq!( + refs[3].resolved_uri, + Some("https://example.com/paper.pdf".to_string()) + ); + assert_eq!( + refs[4].normalized_href, + format!("file://{}", external.display()) + ); + assert_eq!(refs[5].raw_href, "./missing.pptx"); + assert_eq!(refs[5].open_kind, "office"); + assert_eq!(refs[5].exists, Some(false)); + assert!(refs.iter().all(|item| item.source_range.is_some())); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn markdown_attachment_refs_do_not_resolve_parent_directory_relative_paths() { + let root = std::env::temp_dir().join(format!( + "mnote-attachment-ref-parent-relative-{}", + std::process::id() + )); + let owner_dir = root.join("docs"); + std::fs::create_dir_all(&owner_dir).expect("create owner dir"); + let owner = owner_dir.join("page.md"); + + let refs = parse_markdown_attachment_refs( + "[上级目录](../assets/a.pdf)\n", + &owner.display().to_string(), + &format!("file://{}", root.display()), + ); + + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].raw_href, "../assets/a.pdf"); + assert_eq!(refs[0].kind, "unknown"); + assert_eq!(refs[0].resolved_absolute_path, None); + assert_eq!(refs[0].relative_path, None); + assert_eq!(refs[0].exists, None); + + let _ = std::fs::remove_dir_all(&root); + } + /// 固定空引用块行为:`>` 在 GFM AST 中产生 BlockQuote 节点, /// collect_inline_children 返回空 vec → 输出 type=quote content=[]。 #[test] diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs index df100bcd..8428cfb6 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -4,7 +4,7 @@ use crate::routes::local_markdown_parser::{ parse_markdown_attachment_link, parse_markdown_page, split_frontmatter, }; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -852,55 +852,41 @@ mod tests { .iter() .find(|item| item["documentId"].as_str() == Some("local-md:README.md")) .expect("home result"); - assert!( - home["tags"] - .as_array() - .unwrap() - .iter() - .any(|tag| tag.as_str() == Some("alpha")) - ); - assert!( - home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("Daily")) - ); - assert!( - home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("docs/child.md")) - ); - assert!( - home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("assets/spec.pdf")) - ); - assert!( - home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")) - ); - assert!( - home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("office/report.xlsx")) - ); - assert!( - home["publicPath"] - .as_str() - .is_some_and(|path| path.starts_with( - "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" - )) - ); + assert!(home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("docs/child.md"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("office/report.xlsx"))); + assert!(home["publicPath"] + .as_str() + .is_some_and(|path| path.starts_with( + "/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F" + ))); // 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid: let child_search = query_local_search_index( @@ -929,12 +915,11 @@ mod tests { Some("local-mdid:child-page") ); - assert!( - root.join(".mnote") - .join("index") - .join("search-index.json") - .exists() - ); + assert!(root + .join(".mnote") + .join("index") + .join("search-index.json") + .exists()); let mindmap_projection = query_local_search_index( &root, &format!("file://{}", root.display()), @@ -946,19 +931,19 @@ mod tests { false, ) .expect("mindmap projection"); - assert!( - mindmap_projection["results"] - .as_array() - .unwrap() - .iter() - .any(|item| item["resourceType"].as_str() == Some("mindmap") - && item["path"].as_str() == Some("maps/idea.mindmap.json") - && item["publicPath"].as_str().is_some_and(|path| path + assert!(mindmap_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("mindmap") + && item["path"].as_str() == Some("maps/idea.mindmap.json") + && item["publicPath"] + .as_str() + .is_some_and(|path| path .starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri=")) - && item["publicPath"] - .as_str() - .is_some_and(|path| !path.starts_with("/tree?"))) - ); + && item["publicPath"] + .as_str() + .is_some_and(|path| !path.starts_with("/tree?")))); let office_projection = query_local_search_index( &root, &format!("file://{}", root.display()), @@ -970,14 +955,12 @@ mod tests { false, ) .expect("office projection"); - assert!( - office_projection["results"] - .as_array() - .unwrap() - .iter() - .any(|item| item["resourceType"].as_str() == Some("office") - && item["path"].as_str() == Some("office/report.xlsx")) - ); + assert!(office_projection["results"] + .as_array() + .unwrap() + .iter() + .any(|item| item["resourceType"].as_str() == Some("office") + && item["path"].as_str() == Some("office/report.xlsx"))); let _ = fs::remove_dir_all(&root); } @@ -1004,12 +987,10 @@ mod tests { let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); - assert!( - index - .documents - .iter() - .any(|document| document.path == "README.md") - ); + assert!(index + .documents + .iter() + .any(|document| document.path == "README.md")); let child = index .documents .iter() @@ -1025,18 +1006,14 @@ mod tests { let index = read_local_search_index(&root) .expect("read index") .expect("index exists"); - assert!( - !index - .documents - .iter() - .any(|document| document.path == "docs/child.md") - ); - assert!( - index - .documents - .iter() - .any(|document| document.path == "README.md") - ); + assert!(!index + .documents + .iter() + .any(|document| document.path == "docs/child.md")); + assert!(index + .documents + .iter() + .any(|document| document.path == "README.md")); let _ = fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/media.rs b/rust/crates/mnote-web/src/routes/media.rs index e63eb48f..79bdab89 100644 --- a/rust/crates/mnote-web/src/routes/media.rs +++ b/rust/crates/mnote-web/src/routes/media.rs @@ -7,20 +7,20 @@ use crate::transport::convex::{ persist_runtime_command_artifacts, }; use axum::extract::{Multipart, Query, State}; -use axum::http::{HeaderMap, header}; +use axum::http::{header, HeaderMap}; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use bridge_runtime::{ - RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, - build_runtime_command_artifact_plan, + build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeSourceWire, RuntimeTargetWire, }; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; -use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1); diff --git a/rust/crates/mnote-web/src/routes/mindmap_api.rs b/rust/crates/mnote-web/src/routes/mindmap_api.rs index 255b22e5..de7687fe 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_api.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_api.rs @@ -11,15 +11,15 @@ use crate::routes::query_support::{ fetch_documents_meta_via_legacy_cloud, fetch_query_data_via_legacy_cloud, resolve_effective_workspace_id, }; -use axum::Json; use axum::extract::{Extension, Path, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::{ - RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire, - RuntimeTargetWire, apply_mindmap_kernel_commands_to_value, + apply_mindmap_kernel_commands_to_value, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MINDMAP_TRANSPORT: &str = "x-mnote-mindmap-transport"; @@ -469,10 +469,10 @@ pub async fn apply_mindmap_command( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::path::PathBuf; use tower::util::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 3ef68125..30ca8c43 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -250,12 +250,19 @@ fn escape_script_json(value: &str) -> String { value.replace(" &'static str { +fn render_mindmap_standalone_bootstrap_script() -> String { r#""# + .replace( + "__MNOTE_DEV_HOT_BUSTER__", + crate::routes::dev_hot::dev_hot_cache_buster().unwrap_or(""), + ) } #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; @@ -493,6 +504,25 @@ mod tests { assert!(!html.contains("next-app-router")); } + #[test] + fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let script = super::render_mindmap_standalone_bootstrap_script(); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert!(script.contains("const DEV_HOT_BUSTER = \"")); + assert!(script.contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))")); + assert!( + script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)") + ); + assert!( + script.contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)") + ); + } + #[tokio::test] async fn mindmap_api_returns_same_adapter_contract_for_standalone_and_block() { let response = app_with_mindmap_fixture() diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 5c03fa5d..6317291a 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -1,7 +1,7 @@ mod bridge; pub(crate) mod command_support; mod compat; -mod dev_hot; +pub(crate) mod dev_hot; mod documents; mod editor; mod gateway; @@ -42,9 +42,9 @@ pub(crate) use local_folder_source::{ pub(crate) use local_search_index::refresh_local_search_index_for_path; use crate::app::AppState; -use axum::Router; use axum::extract::DefaultBodyLimit; use axum::routing::{any, delete, get, post, put}; +use axum::Router; pub fn build_router(state: AppState) -> Router { let hermes_base_path = state.config().hermes_base_path.clone(); @@ -572,10 +572,10 @@ mod tests { use super::build_router; use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; - use axum::Router; - use axum::body::{Body, to_bytes}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use axum::Router; + use serde_json::{json, Value}; use std::fs; use tower::ServiceExt; @@ -977,7 +977,8 @@ mod tests { "reasonix" ); assert_eq!( - alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]["folder"], + alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"] + ["folder"], true ); assert_eq!( @@ -1000,12 +1001,10 @@ mod tests { .await .expect("bob body"); let bob_payload: Value = serde_json::from_slice(&bob_body).expect("bob json"); - assert!( - bob_payload["result"]["aiPreferences"] - .as_object() - .map(|value| value.is_empty()) - .unwrap_or(false) - ); + assert!(bob_payload["result"]["aiPreferences"] + .as_object() + .map(|value| value.is_empty()) + .unwrap_or(false)); let _ = fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/navigation_recent.rs b/rust/crates/mnote-web/src/routes/navigation_recent.rs index 58136a33..9a1b0a35 100644 --- a/rust/crates/mnote-web/src/routes/navigation_recent.rs +++ b/rust/crates/mnote-web/src/routes/navigation_recent.rs @@ -3,12 +3,12 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; use crate::routes::local_folder_source::ensure_local_workspace_read_access_with_state; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use control_plane::{NavigationRecentRecord, UpsertNavigationRecentInput}; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::path::{Component, Path, PathBuf}; #[derive(Debug, Clone, Default, Deserialize)] @@ -353,9 +353,9 @@ impl EmptyStringExt for String { #[cfg(test)] mod tests { use super::*; - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; - use axum::http::{Request, StatusCode, header}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{header, Request, StatusCode}; use control_plane::{UpsertNavigationRecentInput, UpsertUserInput}; use tower::ServiceExt; diff --git a/rust/crates/mnote-web/src/routes/onlyoffice.rs b/rust/crates/mnote-web/src/routes/onlyoffice.rs index 83ce73a5..c327a00f 100644 --- a/rust/crates/mnote-web/src/routes/onlyoffice.rs +++ b/rust/crates/mnote-web/src/routes/onlyoffice.rs @@ -2,29 +2,29 @@ use crate::app::AppConfig; use crate::app::AppState; use crate::error::WebError; use adapter_onlyoffice::{ - OnlyOfficeCallbackPreparationInput, OnlyOfficeProxyPreparationInput, prepare_callback, - prepare_proxy_request, sign_config, + prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput, + OnlyOfficeProxyPreparationInput, }; -use axum::Json; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; -use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header}; +use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; +use axum::Json; use base64::Engine; use futures_util::{SinkExt, StreamExt}; use hyper::upgrade::Upgraded; use hyper_util::rt::TokioIo; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::env; use std::fs; use std::path::{Path as FsPath, PathBuf}; use std::time::Duration; -use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::handshake::derive_accept_key; use tokio_tungstenite::tungstenite::protocol::Role; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::WebSocketStream; const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js"; const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082"; @@ -1705,10 +1705,9 @@ mod tests { fn stable_doc_key_uses_onlyoffice_safe_characters() { let key = stable_doc_key("asset_1", "kg2abc:def", "", ""); assert!(key.len() <= 128); - assert!( - key.chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')) - ); + assert!(key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); assert!(key.starts_with("asset_1_")); } @@ -1722,10 +1721,9 @@ mod tests { ); assert!(key.len() <= 128); assert!(key.starts_with("mnote_")); - assert!( - key.chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')) - ); + assert!(key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-'))); assert!(!key.contains('/')); assert!(!key.contains(':')); assert!(!key.contains('重')); @@ -2025,11 +2023,8 @@ mod tests { let request = captured.await.expect("captured"); assert_eq!(payload["error"], 0); - assert!( - request.starts_with( - "POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1" - ) - ); + assert!(request + .starts_with("POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1")); assert!(request.contains(r#""status":2"#)); assert!(request.contains(r#""key":"doc_key""#)); } @@ -2084,10 +2079,8 @@ mod tests { assert_eq!(payload["ok"], true); assert_eq!(payload["via"], "forcesave"); - assert!( - request - .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1") - ); + assert!(request + .starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs index e6c7d49e..8ef9e883 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs @@ -2,10 +2,10 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::ToolCallInput; -use axum::Json; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, StatusCode}; -use serde_json::{Value, json}; +use axum::Json; +use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; use std::time::Instant; @@ -587,12 +587,12 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option { #[cfg(test)] mod tests { use super::{direct_block_edit_operations, extract_operations_from_model_text}; - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use axum::routing::post; use axum::{Json, Router}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::fs; use std::sync::Mutex; use tower::util::ServiceExt; @@ -952,18 +952,14 @@ mod tests { .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); - assert!( - payload["message"] - .as_str() - .unwrap_or_default() - .contains("已读取第一段:第一段") - ); - assert!( - payload["message"] - .as_str() - .unwrap_or_default() - .contains("测试123") - ); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("已读取第一段:第一段")); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("测试123")); std::env::remove_var("HERMES_HOME"); let _ = fs::remove_dir_all(&hermes_home); diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index 40975096..d3128e4a 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -3,9 +3,9 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::execute_retired_query_plan; use bridge_runtime::{ - BridgeContext, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, - RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, build_query_request, - execute_runtime_input, execute_runtime_query, + build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext, + RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, + RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, }; use core_protocol::{GetPageMeta, QueryEnvelope}; use serde_json::Value; diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index 62635a22..6fd50138 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -11,15 +11,15 @@ use crate::transport::convex::{ execute_retired_mutation_by_name, execute_retired_query_by_name, persist_runtime_command_artifacts, }; -use axum::Json; use axum::extract::{Extension, Path, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::{ - RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire, - RuntimeTargetWire, build_runtime_command_artifact_plan, + build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, + RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; @@ -1148,8 +1148,8 @@ pub async fn table_empty_trash( #[cfg(test)] mod tests { use super::*; - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::Request; use tower::util::ServiceExt; @@ -1311,12 +1311,11 @@ mod tests { ); assert_eq!(payload["result"]["sourceKind"], "local_folder"); assert!(!root.join("Page").join("map.mindmap.json").exists()); - assert!( - root.join(".mnote") - .join("trash") - .join("map.mindmap.json") - .exists() - ); + assert!(root + .join(".mnote") + .join("trash") + .join("map.mindmap.json") + .exists()); assert_eq!( std::fs::read_to_string(&markdown_path).expect("read markdown after trash"), original_markdown, @@ -1422,13 +1421,11 @@ mod tests { purged_payload["result"]["canonicalCommand"], "tree.resource.purge" ); - assert!( - !root - .join(".mnote") - .join("trash") - .join("map.mindmap.json") - .exists() - ); + assert!(!root + .join(".mnote") + .join("trash") + .join("map.mindmap.json") + .exists()); let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index 9d6eb180..319bc525 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -8,13 +8,13 @@ use crate::routes::query_support::{ use crate::routes::web_shell::load_sidebar_tree_html; use crate::routes::{local_folder_source, local_search_index}; use crate::ssr::pages::search::SearchPage; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_QUERY_NAME: &str = "x-query-name"; @@ -546,10 +546,10 @@ fn stamp_search_headers(headers: &mut HeaderMap) { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::fs; use tower::util::ServiceExt; @@ -788,40 +788,31 @@ mod tests { .expect("home result"); assert_eq!(home["sourceKind"].as_str(), Some("local_folder")); assert_eq!(home["resourceType"].as_str(), Some("markdown")); - assert!( - home["tags"] - .as_array() - .unwrap() - .iter() - .any(|tag| tag.as_str() == Some("alpha")) - ); - assert!( - home["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|link| link.as_str() == Some("Daily")) - ); - assert!( - home["resourceRefs"] - .as_array() - .unwrap() - .iter() - .any(|reference| reference.as_str() == Some("assets/spec.pdf")) - ); - assert!( - root.join(".mnote") - .join("index") - .join("search-index.json") - .exists() - ); - assert!( - payload["recent"] - .as_array() - .unwrap() - .iter() - .any(|item| item["documentId"].as_str() == Some("local-md:README.md")) - ); + assert!(home["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag.as_str() == Some("alpha"))); + assert!(home["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|link| link.as_str() == Some("Daily"))); + assert!(home["resourceRefs"] + .as_array() + .unwrap() + .iter() + .any(|reference| reference.as_str() == Some("assets/spec.pdf"))); + assert!(root + .join(".mnote") + .join("index") + .join("search-index.json") + .exists()); + assert!(payload["recent"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let _ = fs::remove_dir_all(&root); } @@ -930,13 +921,11 @@ mod tests { backlinks_payload["meta"]["queryName"].as_str(), Some("search.local_index.backlinks") ); - assert!( - backlinks_payload["result"]["backlinks"] - .as_array() - .unwrap() - .iter() - .any(|item| item["documentId"].as_str() == Some("local-md:README.md")) - ); + assert!(backlinks_payload["result"]["backlinks"] + .as_array() + .unwrap() + .iter() + .any(|item| item["documentId"].as_str() == Some("local-md:README.md"))); let tags_response = app() .oneshot( diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs index bebc8f70..a3dd9aff 100644 --- a/rust/crates/mnote-web/src/routes/session.rs +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -1,10 +1,10 @@ use crate::app::AppState; use crate::context::RequestContext; -use axum::Json; use axum::extract::{Extension, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use axum::Json; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use control_plane::session_token_hash; use serde::Serialize; @@ -213,10 +213,10 @@ fn stamp_owner_header(headers: &mut HeaderMap) { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use control_plane::{CreateSessionInput, UpsertUserInput, session_token_hash}; + use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput}; use tower::util::ServiceExt; fn app() -> axum::Router { diff --git a/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs b/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs index 4ca15ccf..c7652170 100644 --- a/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs +++ b/rust/crates/mnote-web/src/routes/sidebar_shortcuts.rs @@ -2,12 +2,12 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; -use axum::Json; use axum::extract::{Extension, Path, Query, State}; use axum::http::StatusCode; +use axum::Json; use control_plane::{AppendAuditInput, SidebarShortcutRecord, UpsertSidebarShortcutInput}; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs index 3f27fa4f..0107f0c3 100644 --- a/rust/crates/mnote-web/src/routes/snapshot_support.rs +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -6,7 +6,7 @@ use crate::routes::query_support::{ }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelNodeType, KernelProjectionKind}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Clone)] pub struct ProjectionSnapshotSpec<'a> { diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 187c3351..0efd76ac 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -2,15 +2,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::stream_support::{ - StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, - build_stream_push_delta_hint, load_stream_overview, load_stream_snapshot, - read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, + build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview, + load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, + StreamChangeKind, StreamSnapshotQuery, }; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::stream; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::convert::Infallible; use std::time::Duration; use tokio::time::sleep; @@ -340,9 +340,9 @@ fn stream_event(event_name: &str, payload: &Value) -> Event { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use crate::routes::stream_support::StreamSnapshotQuery; - use axum::body::{Body, to_bytes}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use std::time::Duration; use tokio::time::timeout; diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 0c2c9570..6247e1eb 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -5,13 +5,13 @@ use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{ - ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, - subtree_query, + execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, + ProjectionSnapshotSpec, }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ "page.body.save", @@ -701,9 +701,8 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { use super::{ - StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor, - resolve_stream_scope, + resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, }; use serde_json::json; diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 755c78c4..f763e9b8 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -6,42 +6,42 @@ use crate::routes::command_support::{ execute_runtime_command_via_legacy_cloud_with_artifacts, read_optional_non_empty, }; use crate::routes::local_folder_source::{ - LocalAccessMode, ensure_local_workspace_access_with_state, - ensure_local_workspace_read_access_with_state, execute_local_tree_command_with_sort, - load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, local_workspace_id_from_root_uri, + ensure_local_workspace_access_with_state, ensure_local_workspace_read_access_with_state, + execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot, + load_local_folder_page_tree_snapshot, local_folder_watch_revision, + local_workspace_id_from_root_uri, LocalAccessMode, }; use crate::routes::query_support::{ fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id, }; -use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; +use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use crate::transport::convex::execute_retired_mutation_by_name; use crate::tree_shell::filetree_renderer::{ - FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html, + render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow, }; use crate::tree_shell::filetree_selection::FileTreeSelectionState; use crate::tree_shell::page_renderer::{ - PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html, + render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow, }; use crate::tree_shell::picker_renderer::{ - PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html, + render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow, }; use crate::tree_shell::renderer_input::{ FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, TreeShellRendererInput, }; use crate::tree_shell::runtime_api::{ - TreeShellRuntimeRequest, TreeShellRuntimeResult, - reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, + reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest, + TreeShellRuntimeResult, }; -use axum::Json; use axum::extract::{Extension, Query, State}; -use axum::http::{HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; +use axum::Json; use bridge_runtime::RuntimeCommandEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -850,6 +850,18 @@ fn build_tree_shell_html( exclude_ids: &[String], dataset: &Value, ) -> String { + let source_kind = projection + .get("sourceKind") + .and_then(Value::as_str) + .unwrap_or("convex_workspace"); + let root_uri = projection + .get("rootUri") + .and_then(Value::as_str) + .unwrap_or(""); + let watch_revision = projection + .get("watchRevision") + .cloned() + .unwrap_or(Value::Null); let renderer_input = build_tree_shell_renderer_input( projection, mode, @@ -869,15 +881,9 @@ fn build_tree_shell_html( "channel": channel, "host": host, "mode": mode, - "sourceKind": projection - .get("sourceKind") - .and_then(Value::as_str) - .unwrap_or("convex_workspace"), - "rootUri": projection - .get("rootUri") - .and_then(Value::as_str) - .unwrap_or(""), - "localWatchRevision": projection.get("watchRevision").cloned().unwrap_or(Value::Null), + "sourceKind": source_kind, + "rootUri": root_uri, + "localWatchRevision": watch_revision.clone(), "allowRootPick": allow_root_pick, "excludeIds": exclude_ids, "rendererInput": renderer_input, @@ -892,6 +898,22 @@ fn build_tree_shell_html( }); let app_state_json = serde_json::to_string(&app_state).unwrap_or_else(|_| "{}".into()); let projection_json = serde_json::to_string_pretty(projection).unwrap_or_else(|_| "{}".into()); + let tree_live_bootstrap = serde_json::json!({ + "schema": "mnote.tree_live_bootstrap.v1", + "disabled": false, + "transport": if source_kind == "local_folder" { "local-folder-events" } else { "tree-live-ws" }, + "endpoint": "/api/tree/events", + "wsEndpoint": "/api/realtime/ws", + "workspaceId": workspace_id, + "rootIds": root_node_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| vec![value]) + .unwrap_or_default(), + "initialRevision": watch_revision, + }); + let tree_live_bootstrap_json = + serde_json::to_string(&tree_live_bootstrap).unwrap_or_else(|_| "{}".into()); let initial_tree_html = match mode { "page" => render_initial_page_tree_html(&PageTreeInitialRenderInput { rows: collect_page_tree_render_rows(projection), @@ -1589,7 +1611,7 @@ fn build_tree_shell_html( } - +
@@ -1606,7 +1628,9 @@ fn build_tree_shell_html(
- + + + "##; @@ -1615,9 +1639,22 @@ fn build_tree_shell_html( .replace("__WORKSPACE_ID__", &escape_html(workspace_id)) .replace("__ROOT_LABEL__", &escape_html(root_label)) .replace("__ACTIVE_LABEL__", &escape_html(active_label)) + .replace("__ROOT_URI__", &escape_html(root_uri)) .replace("__PROJECTION_JSON__", &escape_html(&projection_json)) .replace("__INITIAL_TREE_HTML__", &initial_tree_html) .replace("__APP_STATE__", &escape_inline_json(&app_state_json)) + .replace( + "__TREE_SHELL_RUNTIME_SRC__", + &crate::routes::web_shell::mnote_browser_runtime_src("tree-shell-runtime.js"), + ) + .replace( + "__TREE_LIVE_CONTROLLER_SRC__", + &crate::routes::web_shell::mnote_browser_runtime_src("tree-live-controller.js"), + ) + .replace( + "__TREE_LIVE_BOOTSTRAP__", + &escape_inline_json(&tree_live_bootstrap_json), + ) } fn json_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { @@ -2584,10 +2621,10 @@ mod tests { include_str!("../../browser/tree-shell-filetree-dnd-runtime.js"); use super::{ - TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, TreeCommandEnvelopeContext, TreeCommandRequest, - collect_filetree_render_rows, create_command_wire, + collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext, + TreeCommandRequest, TREE_DOCUMENT_COMPAT_ALIAS_CATALOG, }; - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; @@ -2752,20 +2789,14 @@ mod tests { assert!(html.contains("data-rust-action=\"toggle\"")); assert!(html.contains("data-testid=\"tree-node-toggle\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPageTree")); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();") - ); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialPageRenderer = hydrateInitialPageTree();")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyCreatedDocumentLocally")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyRemovedDocumentLocally")); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally") - ); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))") - ); + assert!(TREE_SHELL_RUNTIME_JS + .contains("sourceKind === \"convex_workspace\" && applyCreatedDocumentLocally")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("if (!deletableItems.every((item) => !fileTreeRowById.has(item.rowId)))")); assert!(TREE_SHELL_RUNTIME_JS.contains("application/x-mnote-page-tree-node")); assert!(TREE_SHELL_RUNTIME_JS.contains("页面已拖放到")); assert!(TREE_SHELL_RUNTIME_JS.contains("setAttribute(\"role\", \"treeitem\")")); @@ -2790,11 +2821,8 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("id=\"tree-shell-state\"")); - assert!( - html.contains( - "type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\"" - ) - ); + assert!(html + .contains("type=\"module\" src=\"/api/mnote-browser-runtime/tree-shell-runtime.js\"")); assert!( !html.contains("const stateElement = document.getElementById(\"tree-shell-state\")"), "debug /tree runtime should live in browser/tree-shell-runtime.js, not inline Rust HTML" @@ -2829,23 +2857,17 @@ mod tests { assert!(html.contains("\"contractName\":\"rust_picker_state_reducer_v1\"")); assert!(TREE_SHELL_RUNTIME_JS.contains("applyPickerStateAction")); assert!(TREE_SHELL_RUNTIME_JS.contains("postPickerPickResultToHost")); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })") - ); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })") - ); + assert!(TREE_SHELL_RUNTIME_JS + .contains("applyPickerFocusByItemKey(\"__root__\", { focusDom: true })")); + assert!(TREE_SHELL_RUNTIME_JS + .contains("applyPickerFocusByItemKey(item.nodeId, { focusDom: true })")); assert!(TREE_SHELL_RUNTIME_JS.contains("const shouldFocusDom = options.focusDom === true")); assert!(TREE_SHELL_RUNTIME_JS.contains("if (shouldFocusDom) focusPickerRowElement")); assert!(TREE_SHELL_RUNTIME_JS.contains("patchPickerActiveDom")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialPickerTree")); assert!(html.contains("tabindex=\"")); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("const usedRustInitialRenderer = hydrateInitialRenderer();") - ); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); assert!(TREE_SHELL_RUNTIME_JS.contains("__MNOTE_TREE_SHELL_OVERRIDE__")); } @@ -2885,18 +2907,12 @@ mod tests { assert!( TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)") ); - assert!( - TREE_SHELL_FILETREE_MENU_RUNTIME_JS - .contains("function buildFileTreeMenuTarget(context") - ); - assert!( - TREE_SHELL_FILETREE_DND_RUNTIME_JS - .contains("function createTreeShellFileTreeDndRuntime(context)") - ); - assert!( - TREE_SHELL_RENDER_RUNTIME_JS - .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";") - ); + assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS + .contains("function buildFileTreeMenuTarget(context")); + assert!(TREE_SHELL_FILETREE_DND_RUNTIME_JS + .contains("function createTreeShellFileTreeDndRuntime(context)")); + assert!(TREE_SHELL_RENDER_RUNTIME_JS + .contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";")); assert!( TREE_SHELL_RUNTIME_JS.contains("if (rowId && getFileTreeRowDocumentId(renameItem))") ); @@ -2904,10 +2920,8 @@ mod tests { assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("documentId: ownerDocumentId || null")); assert!(TREE_SHELL_RENDER_RUNTIME_JS.contains("dragover")); assert!(TREE_SHELL_RUNTIME_JS.contains("hydrateInitialFileTree")); - assert!( - TREE_SHELL_RUNTIME_JS - .contains("const usedRustInitialRenderer = hydrateInitialRenderer();") - ); + assert!(TREE_SHELL_RUNTIME_JS + .contains("const usedRustInitialRenderer = hydrateInitialRenderer();")); } #[tokio::test] @@ -3065,8 +3079,16 @@ mod tests { let _ = std::fs::remove_dir_all(&root); assert_eq!(response.status(), StatusCode::OK); - assert!(TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch")); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__")); + assert!(html.contains("/api/mnote-browser-runtime/tree-live-controller.js")); + assert!(html.contains("data-mnote-root-uri=")); + assert!(TREE_SHELL_RUNTIME_JS.contains("tree:local-folder-watch-batch")); assert!(TREE_SHELL_RUNTIME_JS.contains("refreshLocalFolderSnapshot")); + assert!(!TREE_SHELL_RUNTIME_JS.contains("/api/tree/local-folder-watch")); assert!(!TREE_SHELL_RUNTIME_JS.contains("window.location.reload")); } @@ -3120,8 +3142,8 @@ mod tests { } #[tokio::test] - async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint() - { + async fn tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint( + ) { let root = std::env::temp_dir().join(format!("mnote-local-tree-command-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); @@ -3236,12 +3258,11 @@ mod tests { String::from_utf8_lossy(&move_body) ); assert!(!root.join("重命名页面").exists()); - assert!( - root.join("docs") - .join("重命名页面") - .join("重命名页面.md") - .exists() - ); + assert!(root + .join("docs") + .join("重命名页面") + .join("重命名页面.md") + .exists()); let move_payload: Value = serde_json::from_slice(&move_body).expect("move json"); let moved_document_id = move_payload["result"]["documentId"] .as_str() @@ -3281,12 +3302,11 @@ mod tests { .as_str() .expect("copied document id") .to_string(); - assert!( - root.join("docs") - .join("重命名页面 2") - .join("重命名页面 2.md") - .exists() - ); + assert!(root + .join("docs") + .join("重命名页面 2") + .join("重命名页面 2.md") + .exists()); let folder_response = app() .oneshot( @@ -3319,13 +3339,12 @@ mod tests { .expect("response"); assert_eq!(delete_response.status(), StatusCode::OK); assert!(!root.join("docs").join("重命名页面").exists()); - assert!( - root.join(".mnote") - .join("trash") - .join("重命名页面") - .join("重命名页面.md") - .exists() - ); + assert!(root + .join(".mnote") + .join("trash") + .join("重命名页面") + .join("重命名页面.md") + .exists()); assert!(root.join(".mnote").join("trash-index.json").exists()); assert!(!root.join(".mnote").join("page-ids.json").exists()); @@ -3352,12 +3371,11 @@ mod tests { "{}", String::from_utf8_lossy(&restore_body) ); - assert!( - root.join("docs") - .join("重命名页面") - .join("重命名页面.md") - .exists() - ); + assert!(root + .join("docs") + .join("重命名页面") + .join("重命名页面.md") + .exists()); let restore_payload: Value = serde_json::from_slice(&restore_body).expect("restore json"); assert_eq!( restore_payload["result"]["documentId"].as_str(), @@ -3573,12 +3591,10 @@ mod tests { assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(payload["ok"], false); assert_eq!(payload["code"], "local_folder_root_escape"); - assert!( - payload["message"] - .as_str() - .unwrap_or_default() - .contains("root") - ); + assert!(payload["message"] + .as_str() + .unwrap_or_default() + .contains("root")); assert!(payload["requestId"].as_str().unwrap_or_default().len() > 0); assert_eq!( headers @@ -3747,10 +3763,8 @@ mod tests { assert!(filetree_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); - assert!( - filetree_html - .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"") - ); + assert!(filetree_html + .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); assert!(filetree_html.contains( "\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]" )); @@ -3778,10 +3792,8 @@ mod tests { assert!(picker_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); - assert!( - picker_html - .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"") - ); + assert!(picker_html + .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); } #[tokio::test] @@ -4197,13 +4209,11 @@ mod tests { Some("convex://workspace/ws_demo") ); assert_eq!(create_wire.source.workspace_id.as_deref(), Some("ws_demo")); - assert!( - create_wire - .source - .capabilities - .iter() - .any(|capability| capability == "execute-command") - ); + assert!(create_wire + .source + .capabilities + .iter() + .any(|capability| capability == "execute-command")); let rename_wire = create_command_wire( &context, @@ -4395,31 +4405,21 @@ mod tests { #[test] fn tree_documents_compat_alias_catalog_marks_cloud_retirement_boundary() { let aliases = TREE_DOCUMENT_COMPAT_ALIAS_CATALOG; - assert!( - aliases - .iter() - .all(|entry| entry.compat_command.starts_with("documents.")) - ); - assert!( - aliases - .iter() - .all(|entry| entry.preferred_command.starts_with("tree.")) - ); - assert!( - aliases - .iter() - .all(|entry| entry.source_kind == "convex_workspace") - ); - assert!( - aliases - .iter() - .all(|entry| entry.retained_for.contains("legacy cloud")) - ); - assert!( - aliases - .iter() - .all(|entry| entry.retirement_condition.contains("emit tree.")) - ); + assert!(aliases + .iter() + .all(|entry| entry.compat_command.starts_with("documents."))); + assert!(aliases + .iter() + .all(|entry| entry.preferred_command.starts_with("tree."))); + assert!(aliases + .iter() + .all(|entry| entry.source_kind == "convex_workspace")); + assert!(aliases + .iter() + .all(|entry| entry.retained_for.contains("legacy cloud"))); + assert!(aliases + .iter() + .all(|entry| entry.retirement_condition.contains("emit tree."))); assert!(aliases.iter().any(|entry| { entry.compat_command == "documents.delete" && entry.preferred_command == "tree.node.archive" diff --git a/rust/crates/mnote-web/src/routes/tree_view_state.rs b/rust/crates/mnote-web/src/routes/tree_view_state.rs index b0263cbc..9c631e0f 100644 --- a/rust/crates/mnote-web/src/routes/tree_view_state.rs +++ b/rust/crates/mnote-web/src/routes/tree_view_state.rs @@ -3,12 +3,12 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::gateway::current_actor_id; use crate::routes::local_folder_source::local_workspace_id_from_root_uri; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const SIDEBAR_TREE_SCOPE_KIND: &str = "sidebar_tree"; const SIDEBAR_TREE_VIEW_STATE_KEY: &str = "sidebarTreeViewState.v1"; @@ -265,13 +265,11 @@ fn normalize_state( } object.insert( "scrollTop".to_string(), - json!( - object - .get("scrollTop") - .and_then(Value::as_f64) - .filter(|value| value.is_finite() && *value >= 0.0) - .unwrap_or(0.0) - ), + json!(object + .get("scrollTop") + .and_then(Value::as_f64) + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or(0.0)), ); if !object.contains_key("updatedAtMs") { object.insert("updatedAtMs".to_string(), json!(0)); diff --git a/rust/crates/mnote-web/src/routes/ui_preferences.rs b/rust/crates/mnote-web/src/routes/ui_preferences.rs index a802b3ef..777928e4 100644 --- a/rust/crates/mnote-web/src/routes/ui_preferences.rs +++ b/rust/crates/mnote-web/src/routes/ui_preferences.rs @@ -6,13 +6,13 @@ use crate::routes::gateway::current_actor_id; use crate::routes::local_folder_source::{ local_root_has_workspace_manifest, local_workspace_id_from_root_uri, }; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use control_plane::{UpsertUserInput, UpsertUserUiPreferenceInput, UserUiPreferenceRecord}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use serde_json::{Map, json}; +use serde_json::{json, Map}; use std::collections::BTreeMap; pub(crate) const SOURCE_FAMILY_MY_SPACE: &str = "my_space"; diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 98712a94..5acb3bcf 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4,8 +4,8 @@ use crate::document_buffer_store::{self}; use crate::error::WebError; use crate::page_aggregate::PageAggregate; use crate::routes::documents::{ - DocumentContentQuery, DocumentMetaQuery, load_document_content_result, - load_document_meta_result, + load_document_content_result, load_document_meta_result, DocumentContentQuery, + DocumentMetaQuery, }; use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ @@ -16,28 +16,28 @@ use crate::routes::local_folder_source::{ }; use crate::routes::query_support::execute_runtime_query_against_data; use crate::routes::snapshot_support::{ - ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, projection_query, + execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec, }; use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows}; use crate::ssr::pages::document::DocumentPage; use crate::tree_shell::filetree_renderer::{ - FileTreeInitialRenderInput, render_initial_filetree_html, + render_initial_filetree_html, FileTreeInitialRenderInput, }; -use crate::tree_shell::page_renderer::{PageTreeInitialRenderInput, render_initial_page_tree_html}; +use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput}; use crate::workspace_shell::{ - WorkspaceShellProjection, apply_active_page, build_workspace_shell_projection, - render_workspace_shell_sidebar_html, + apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html, + WorkspaceShellProjection, }; -use axum::Json; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; -use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri, header}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use control_plane::UpsertNavigationRecentInput; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::fs; use std::path::{Component, Path as FsPath, PathBuf}; @@ -350,7 +350,10 @@ pub async fn document_page_shell( .unwrap_or_default(), render_document_title_controller_script(), render_editor_island_adapter_script(), - r#""#.to_string(), + format!( + r#""#, + mnote_browser_runtime_src("document-conflict-panel-runtime.js") + ), ); let mut response = Html(html).into_response(); stamp_shell_headers(response.headers_mut(), "document"); @@ -817,13 +820,38 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { "# } -pub(crate) fn render_editor_island_adapter_script() -> &'static str { - r#""# +pub(crate) fn mnote_browser_runtime_src(asset: &str) -> String { + let base = format!("/api/mnote-browser-runtime/{asset}"); + if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { + format!("{base}?devHot={cache_buster}") + } else { + base + } } -pub(crate) fn render_editor_runtime_preload_links() -> &'static str { - r#" - "# +pub(crate) fn render_editor_island_adapter_script() -> String { + format!( + r#""#, + mnote_browser_runtime_src("document-editor-adapter-runtime.js") + ) +} + +fn leptos_tiptap_runtime_src(asset: &str) -> String { + let base = format!("/api/leptos-tiptap-runtime/{asset}"); + if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { + format!("{base}?devHot={cache_buster}") + } else { + base + } +} + +pub(crate) fn render_editor_runtime_preload_links() -> String { + format!( + r#" + "#, + leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island.js"), + leptos_tiptap_runtime_src("mnote-leptos-tiptap-spike-island_bg.wasm") + ) } fn runtime_asset_root() -> PathBuf { @@ -859,7 +887,47 @@ fn runtime_asset_content_type(asset_path: &str) -> &'static str { } fn runtime_asset_cache_control() -> &'static str { - "public, max-age=3600, stale-while-revalidate=86400" + if crate::routes::dev_hot::dev_hot_reload_enabled() { + "no-store" + } else { + "public, max-age=3600, stale-while-revalidate=86400" + } +} + +fn browser_runtime_js_body(js: &'static str) -> Body { + let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else { + return Body::from(js); + }; + Body::from(rewrite_browser_runtime_imports_for_dev_hot( + js, + cache_buster, + )) +} + +fn rewrite_browser_runtime_imports_for_dev_hot(js: &str, cache_buster: &str) -> String { + let suffix = format!("?devHot={cache_buster}"); + js.replace(".js';", &format!(".js{suffix}';")) + .replace(".js\";", &format!(".js{suffix}\";")) + .replace(".js');", &format!(".js{suffix}');")) + .replace(".js\");", &format!(".js{suffix}\");")) + .replace(".js'\n", &format!(".js{suffix}'\n")) + .replace(".js\"\n", &format!(".js{suffix}\"\n")) +} + +fn runtime_asset_body(asset_path: &str, bytes: Vec) -> Body { + let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() else { + return Body::from(bytes); + }; + if !asset_path.ends_with(".js") { + return Body::from(bytes); + } + match String::from_utf8(bytes) { + Ok(js) => Body::from(rewrite_browser_runtime_imports_for_dev_hot( + &js, + cache_buster, + )), + Err(error) => Body::from(error.into_bytes()), + } } pub async fn editor_image_placeholder_asset() -> Response { @@ -1509,7 +1577,7 @@ pub async fn resource_open_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1523,7 +1591,7 @@ pub async fn local_upload_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1537,7 +1605,7 @@ pub async fn sidebar_tree_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1551,7 +1619,7 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1565,7 +1633,7 @@ pub async fn sidebar_page_settings_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1579,7 +1647,7 @@ pub async fn sidebar_shell_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1593,7 +1661,7 @@ pub async fn sidebar_workspace_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1607,7 +1675,7 @@ pub async fn sidebar_page_tree_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1621,7 +1689,7 @@ pub async fn sidebar_tree_live_apply_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1635,7 +1703,7 @@ pub async fn sidebar_filetree_open_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1649,7 +1717,7 @@ pub async fn sidebar_filetree_command_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1663,7 +1731,7 @@ pub async fn sidebar_filetree_upload_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1677,7 +1745,7 @@ pub async fn sidebar_attachment_open_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1691,7 +1759,7 @@ pub async fn filetree_keyboard_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1705,7 +1773,7 @@ pub async fn filetree_dnd_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1719,7 +1787,7 @@ pub async fn filetree_context_menu_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1733,7 +1801,7 @@ pub async fn filetree_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1747,7 +1815,7 @@ pub async fn filetree_selection_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1761,7 +1829,7 @@ pub async fn tree_live_controller_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1775,7 +1843,7 @@ pub async fn tree_shell_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1789,7 +1857,7 @@ pub async fn tree_shell_render_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1803,7 +1871,7 @@ pub async fn tree_shell_page_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1817,7 +1885,7 @@ pub async fn tree_shell_state_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1831,7 +1899,7 @@ pub async fn tree_shell_icons_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1845,7 +1913,7 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1859,7 +1927,7 @@ pub async fn tree_shell_filetree_menu_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1873,7 +1941,7 @@ pub async fn tree_shell_filetree_dnd_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1887,7 +1955,7 @@ pub async fn tree_shell_picker_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1901,7 +1969,7 @@ pub async fn tree_shell_dom_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1916,7 +1984,7 @@ pub async fn document_conflict_panel_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1930,7 +1998,7 @@ pub async fn document_pane_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1944,7 +2012,7 @@ pub async fn document_mindmap_host_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1958,7 +2026,7 @@ pub async fn document_resource_tab_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1972,7 +2040,7 @@ pub async fn document_session_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -1986,7 +2054,7 @@ pub async fn document_slash_position_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -2000,7 +2068,7 @@ pub async fn document_tiptap_conversion_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -2014,7 +2082,7 @@ pub async fn document_editor_adapter_runtime_asset() -> Response { ) .header(header::CACHE_CONTROL, runtime_asset_cache_control()) .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .body(Body::from(JS)) + .body(browser_runtime_js_body(JS)) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -2059,7 +2127,7 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path) -> Result Result<(), WebError> { + let Some(refs) = aggregate.body.attachment_refs.as_array_mut() else { + return Ok(()); + }; + for item in refs { + let Some(object) = item.as_object_mut() else { + continue; + }; + let resolved_uri = object + .get("resolvedUri") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with("file://")); + let Some(resolved_uri) = resolved_uri else { + continue; + }; + let kind = object + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default(); + let owner_root_uri = object + .get("ownerRootUri") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + if kind == "pageLocal" + && !owner_root_uri.is_empty() + && (resolved_uri == owner_root_uri + || resolved_uri + .strip_prefix(owner_root_uri) + .is_some_and(|suffix| suffix.starts_with('/'))) + { + object.insert("authorized".to_string(), Value::Bool(true)); + continue; + } + let access = state + .control_plane() + .resolve_access(&context.auth.actor_id, resolved_uri) + .map_err(|error| { + WebError::internal(format!("SQLite 控制面附件授权解析失败: {error}")) + })?; + object.insert( + "authorized".to_string(), + Value::Bool(access.permission != "none"), + ); + } + Ok(()) +} + fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) { let value = document_id .chars() @@ -2497,10 +2619,10 @@ fn block_local_page_aggregate_for_test() { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; - use axum::body::{Body, to_bytes}; - use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri, header}; + use axum::body::{to_bytes, Body}; + use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use control_plane::{DirectoryGrantInput, UpsertUserInput, UpsertUserUiPreferenceInput}; use serde_json::Value; use std::time::{Duration, Instant}; @@ -2903,16 +3025,14 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("document") ); - assert!( - response - .headers() - .get_all("set-cookie") - .iter() - .any(|value| value - .to_str() - .unwrap_or_default() - .contains("mnote_recent_page_id=doc_1")) - ); + assert!(response + .headers() + .get_all("set-cookie") + .iter() + .any(|value| value + .to_str() + .unwrap_or_default() + .contains("mnote_recent_page_id=doc_1"))); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); @@ -2980,11 +3100,8 @@ mod tests { assert!(resource_runtime.contains( "if (documentId) url.pathname = `/documents/${encodeURIComponent(documentId)}`;" )); - assert!( - !html.contains( - "nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()" - ) - ); + assert!(!html + .contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()")); assert!(runtime.contains("runtime.default({ module_or_path: wasmUrl })")); let slash_runtime = DOCUMENT_SLASH_POSITION_RUNTIME_JS; assert!(slash_runtime.contains("positionSlashMenuForRoot")); @@ -3039,15 +3156,10 @@ mod tests { ); assert!(conversion_runtime.contains("localAttachmentClassForTiptapHref")); assert!(conversion_runtime.contains("mnote-uploaded-attachment-code")); - assert!( - conversion_runtime - .contains("class: mergeClassNames(mark.attrs.class, attachmentClass)") - ); - assert!( - session_runtime.contains( - "const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);" - ) - ); + assert!(conversion_runtime + .contains("class: mergeClassNames(mark.attrs.class, attachmentClass)")); + assert!(session_runtime + .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); assert!(session_runtime.contains( "const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);" )); @@ -3081,17 +3193,13 @@ mod tests { resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>") ); assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;")); - assert!( - resource_runtime - .contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);") - ); + assert!(resource_runtime + .contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")); assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);")); assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);")); assert!(resource_runtime.contains("data-mnote-active-resource-width-type")); assert!(resource_runtime.contains("mnote:active-resource-tab-changed")); - assert!( - SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)") - ); + assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("activeResourceWidthPreference(options)")); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote:active-resource-tab-changed")); } @@ -3171,6 +3279,129 @@ mod tests { ); } + #[tokio::test] + async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let response = app() + .oneshot( + Request::builder() + .uri("/api/mnote-browser-runtime/tree-live-controller.js") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert_eq!(response.status(), StatusCode::OK); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("cache-control"); + assert_eq!( + cache_control, "no-store", + "dev:hot 下 browser runtime 不能缓存,否则热更新后仍会执行旧 JS" + ); + } + + #[tokio::test] + async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let response = app() + .oneshot( + Request::builder() + .uri("/api/mnote-browser-runtime/sidebar-tree-runtime.js") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let js = String::from_utf8(body.to_vec()).expect("utf8"); + assert!( + js.contains("from './sidebar-workspace-runtime.js?devHot="), + "dev:hot 下 browser runtime 的静态子模块 import 必须跟随同一个 cache buster" + ); + assert!( + js.contains("from './sidebar-filetree-command-runtime.js?devHot="), + "文件树命令 runtime 子模块也不能复用旧缓存" + ); + } + + #[tokio::test] + async fn editor_runtime_preload_links_use_dev_hot_buster() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let html = super::render_editor_runtime_preload_links(); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert!( + html.contains("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js?devHot=") + ); + assert!(html.contains( + "/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island_bg.wasm?devHot=" + )); + } + + #[test] + fn document_editor_adapter_propagates_dev_hot_to_island_runtime() { + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("new URL(import.meta.url)")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("fetch(withDevHot('/api/leptos-tiptap-runtime/manifest.json'))")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.entryAssetPath}`)")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("withDevHot(`/api/leptos-tiptap-runtime/${manifest.wasmAssetPath}`)")); + } + + #[tokio::test] + async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let response = app() + .oneshot( + Request::builder() + .uri("/api/leptos-tiptap-runtime/mnote-leptos-tiptap-spike-island.js") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let js = String::from_utf8(body.to_vec()).expect("utf8"); + assert!( + js.contains("from './snippets/leptos-tiptap-") + && js.contains("/bridge_runtime.js?devHot="), + "dev:hot 下 wasm-bindgen snippets 子模块不能继续使用旧缓存" + ); + assert!( + js.contains("from \"./snippets/mnote-leptos-tiptap-spike-") + && js.contains("/inline0.js?devHot="), + "无分号的 wasm-bindgen import 也必须带 cache buster" + ); + } + #[tokio::test] async fn leptos_tiptap_runtime_assets_are_cacheable() { let manifest_response = app() @@ -3365,11 +3596,9 @@ mod tests { assert_eq!(payload["result"]["head"]["title"], "Local Aggregate"); assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false); assert_eq!(payload["result"]["body"]["revision"], 0); - assert!( - payload["result"]["body"]["content"] - .to_string() - .contains("Local Heading") - ); + assert!(payload["result"]["body"]["content"] + .to_string() + .contains("Local Heading")); } #[tokio::test] @@ -3432,6 +3661,107 @@ mod tests { assert!(aggregate.body.content.to_string().contains("Grant Heading")); } + #[tokio::test] + async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants() { + let root = temp_root("mnote-local-page-aggregate-attachment-auth-root"); + let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed"); + let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied"); + std::fs::create_dir_all(root.join("Page")).expect("create page dir"); + std::fs::write(root.join("Page").join("local.pdf"), b"local").expect("local pdf"); + let allowed_external = allowed_external_root.join("allowed.pdf"); + let denied_external = denied_external_root.join("denied.pdf"); + std::fs::write(&allowed_external, b"allowed").expect("allowed external"); + std::fs::write(&denied_external, b"denied").expect("denied external"); + std::fs::write( + root.join("Page").join("Page.md"), + format!( + "[本页](./local.pdf)\n[授权外部](file://{})\n[未授权外部](file://{})\n", + allowed_external.display(), + denied_external.display() + ), + ) + .expect("write md"); + + let root_uri = format!("file://{}", root.display()); + let allowed_root_uri = format!("file://{}", allowed_external_root.display()); + let state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + enable_editor_actor: true, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + init_local_workspace(&root, "owner_user"); + grant_local_workspace_read_access(&state, "user_test", &root_uri, &root); + grant_local_workspace_read_access(&state, "user_other", &root_uri, &root); + grant_local_workspace_read_access( + &state, + "user_test", + &allowed_root_uri, + &allowed_external_root, + ); + + let context = request_context("user_test", "user"); + let aggregate = super::build_page_aggregate_snapshot( + &state, + &context, + "local-md:Page~2FPage.md", + None, + Some("local_folder"), + Some(&root_uri), + ) + .await + .expect("aggregate with attachment refs"); + let refs = aggregate.body.attachment_refs.as_array().expect("refs"); + let by_label = |label: &str| { + refs.iter() + .find(|item| item["label"].as_str() == Some(label)) + .expect("attachment ref") + }; + assert_eq!(by_label("本页")["authorized"], true); + assert_eq!(by_label("授权外部")["authorized"], true); + assert_eq!(by_label("未授权外部")["authorized"], false); + + let other_context = request_context("user_other", "user"); + let other_aggregate = super::build_page_aggregate_snapshot( + &state, + &other_context, + "local-md:Page~2FPage.md", + None, + Some("local_folder"), + Some(&root_uri), + ) + .await + .expect("other user aggregate with attachment refs"); + let other_refs = other_aggregate + .body + .attachment_refs + .as_array() + .expect("other refs"); + let other_allowed_external = other_refs + .iter() + .find(|item| item["label"].as_str() == Some("授权外部")) + .expect("other allowed external ref"); + assert_eq!(other_allowed_external["authorized"], false); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&allowed_external_root); + let _ = std::fs::remove_dir_all(&denied_external_root); + } + #[tokio::test] async fn local_page_aggregate_does_not_block_leptos_runtime_asset_request() { let root = std::env::temp_dir().join(format!( @@ -3661,12 +3991,10 @@ mod tests { let put_payload: Value = serde_json::from_slice(&put_body).expect("json"); assert_eq!(put_payload["result"]["userId"], "alice"); assert_ne!(put_payload["result"]["workspaceId"], "default"); - assert!( - put_payload["result"]["scopeId"] - .as_str() - .expect("scope id") - .starts_with("filetree:") - ); + assert!(put_payload["result"]["scopeId"] + .as_str() + .expect("scope id") + .starts_with("filetree:")); let get_response = app .clone() @@ -3811,27 +4139,20 @@ mod tests { assert!(session_runtime.contains("__MNOTE_LEPTOS_MINDMAP_BRIDGES__")); assert!(session_runtime.contains("/api/local-folder/events")); assert!(session_runtime.contains("localFolderEventChannelKey")); - assert!( - session_runtime.contains("url.searchParams.set('documentId', session.documentId);") - ); + assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);")); assert!(session_runtime.contains("if (!documentId) return;")); assert!(session_runtime.contains("new EventSource(url.toString())")); assert!(session_runtime.contains("localFolderEventRegistry")); - assert!( - session_runtime.contains( - "if (!response.ok) {\n if (session.sourceKind === 'local_folder')" - ) - ); + assert!(session_runtime + .contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')")); assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange")); assert!(session_runtime.contains("kind.includes('Modify(Name')")); assert!(session_runtime.contains("targetSession.views.size === 0")); assert!(session_runtime.contains("session.views.size === 0")); assert!(session_runtime.contains("targetSession.saving")); assert!(session_runtime.contains("lastSelfSaveSignalAt")); - assert!( - DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS - .contains("clearSessionConflictSurface(view.session);") - ); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("clearSessionConflictSurface(view.session);")); assert!(session_runtime.contains("command: 'replaceContent'")); assert!(session_runtime.contains("external-change-conflict")); assert!(session_runtime.contains("mnote-editor-conflict-panel")); @@ -3840,10 +4161,8 @@ mod tests { assert!(session_runtime.contains("mnote-conflict-open-diff")); assert!(session_runtime.contains("mnote-conflict-merge-text")); assert!(session_runtime.contains("mnote-conflict-merge-save")); - assert!( - session_runtime - .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)") - ); + assert!(session_runtime + .contains("runtime.mountSessionConflictPanel(view.runtimeDescriptor.root, panel)")); assert!(session_runtime.contains("agent run")); assert!(!session_runtime.contains( "setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);" @@ -3854,67 +4173,45 @@ mod tests { fn document_conflict_panel_runtime_contains_dom_helpers() { const DOCUMENT_CONFLICT_PANEL_RUNTIME_JS: &str = include_str!("../../browser/document-conflict-panel-runtime.js"); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function clearSessionConflictSurface")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function createSessionConflictPanel")); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("data-testid', 'mnote-editor-conflict-panel") - ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("data-testid', 'mnote-conflict-accept-disk") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("data-testid', 'mnote-editor-conflict-panel")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("data-testid', 'mnote-conflict-accept-disk")); assert!( DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-open-diff") ); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onAcceptDisk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onKeepCurrent")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onOpenDiff")); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("function populateSessionConflictDiffPanel") - ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("data-testid', 'mnote-conflict-current-text") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("function populateSessionConflictDiffPanel")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("data-testid', 'mnote-conflict-current-text")); assert!( DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("data-testid', 'mnote-conflict-disk-text") ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("data-testid', 'mnote-conflict-merge-use-current") - ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("data-testid', 'mnote-conflict-merge-use-disk") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("data-testid', 'mnote-conflict-merge-use-current")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("data-testid', 'mnote-conflict-merge-use-disk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("currentMergeText")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseCurrent")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onUseDisk")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("onSaveMerge")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function mountSessionConflictPanel")); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("runSessionConflictAction: runSessionConflictAction") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("runSessionConflictAction: runSessionConflictAction")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function runSessionConflictAction")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function acceptDiskVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function keepCurrentEditorVersion")); assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("function writeMergedConflictResult")); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion") - ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("keepCurrentEditorVersion: keepCurrentEditorVersion") - ); - assert!( - DOCUMENT_CONFLICT_PANEL_RUNTIME_JS - .contains("writeMergedConflictResult: writeMergedConflictResult") - ); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS.contains("acceptDiskVersion: acceptDiskVersion")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("keepCurrentEditorVersion: keepCurrentEditorVersion")); + assert!(DOCUMENT_CONFLICT_PANEL_RUNTIME_JS + .contains("writeMergedConflictResult: writeMergedConflictResult")); } #[test] @@ -3937,10 +4234,8 @@ mod tests { assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession")); assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')")); - assert!( - DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS - .contains("syncPageAggregateScript({ pageAggregateScriptId") - ); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("syncPageAggregateScript({ pageAggregateScriptId")); assert!( DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("data-mnote-page-options-local-write-at") ); @@ -3955,10 +4250,8 @@ mod tests { DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js") ); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("enhanceEditorAttachmentLinksSoon")); - assert!( - DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS - .contains("window.__mnoteEnhanceEditorAttachmentLinks") - ); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("window.__mnoteEnhanceEditorAttachmentLinks")); assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap")); @@ -4121,10 +4414,8 @@ mod tests { assert!(runtime.contains("typeof payload.text === 'string'")); assert!(runtime.contains("payload.type === 'hard_break'")); assert!(runtime.contains("typeof body?.fileVersion === 'string'")); - assert!( - DOCUMENT_SESSION_RUNTIME_JS - .contains("expectedFileVersion: session.conflictDetectionKey") - ); + assert!(DOCUMENT_SESSION_RUNTIME_JS + .contains("expectedFileVersion: session.conflictDetectionKey")); assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); assert!(runtime.contains("blockType: 'mindmap'")); assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)")); diff --git a/rust/crates/mnote-web/src/routes/ws.rs b/rust/crates/mnote-web/src/routes/ws.rs index 070e976c..922f3719 100644 --- a/rust/crates/mnote-web/src/routes/ws.rs +++ b/rust/crates/mnote-web/src/routes/ws.rs @@ -2,13 +2,13 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::stream_support::{ - StreamSnapshotQuery, build_stream_push_delta_hint, load_stream_snapshot, + build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery, }; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; use axum::response::Response; use futures_util::StreamExt; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use tokio::sync::broadcast::error::RecvError; pub async fn socket( diff --git a/rust/crates/mnote-web/src/ssr/pages/home.rs b/rust/crates/mnote-web/src/ssr/pages/home.rs index 392641f9..abc0f60e 100644 --- a/rust/crates/mnote-web/src/ssr/pages/home.rs +++ b/rust/crates/mnote-web/src/ssr/pages/home.rs @@ -33,6 +33,9 @@ pub fn HomePage( /// 是否显示管理员授权入口 #[prop(optional)] show_admin_access_policy: bool, + /// 是否启用树实时流 + #[prop(optional, default = true)] + enable_tree_live: bool, ) -> impl IntoView { let active_page_id = active_page_id.unwrap_or_default(); let active_page_title = active_page_title @@ -50,7 +53,6 @@ pub fn HomePage( .as_deref() .map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}")) .unwrap_or_else(|| format!("/documents/{active_page_id}")); - let enable_tree_live = false; view! { {move || if has_active_page { diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 5252a314..1f0cc2a8 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -24,6 +24,15 @@ const SIDEBAR_TREE_JS: &str = r##" "##; +fn browser_runtime_src(asset: &str) -> String { + let base = format!("/api/mnote-browser-runtime/{asset}"); + if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { + format!("{base}?devHot={cache_buster}") + } else { + base + } +} + /// MNOTE Wolai 风格页面布局 /// /// 包含左侧栏 + 内容区的双栏布局。 @@ -152,17 +161,17 @@ pub fn PageLayout( - - - - - - - - - + + + + + + + + + - +
@@ -385,6 +394,29 @@ mod tests { assert!(html.contains(r#"data-mnote-shortcut-kind="page""#)); } + #[test] + fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1"); + let html = crate::ssr::render_view(leptos::view! { + +
"正文"
+
+ }); + std::env::remove_var("MNOTE_WEB_DEV_HOT_RELOAD"); + + assert!( + html.contains("/api/mnote-browser-runtime/tree-live-controller.js?devHot="), + "dev:hot 下 tree live controller URL 必须带 cache buster,避免浏览器继续执行旧 WS/SSE 逻辑" + ); + assert!( + html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js?devHot="), + "dev:hot 下 sidebar runtime URL 也必须带 cache buster" + ); + } + #[test] fn sidebar_runtime_supports_shortcuts_and_scoped_filetree() { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/sidebar/shortcuts")); @@ -587,12 +619,11 @@ mod tests { } #[test] - fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() { + fn sidebar_tree_runtime_uses_local_folder_events_without_browser_reload() { assert!(SIDEBAR_TREE_RUNTIME_JS.contains("startLocalFolderSidebarWatch")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("refreshLocalFolderSidebarSnapshot")); assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentWorkspaceId()")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var rootUri = currentRootUri();")); - assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/sidebar")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/projections/file")); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS @@ -604,6 +635,18 @@ mod tests { assert!( SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied") ); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-disabled") + ); + assert!( + TREE_LIVE_CONTROLLER_JS.contains("body.getAttribute('data-mnote-source-kind')"), + "根入口由 SSR body 暴露 local_folder 时,tree live 不能误走 retired Convex WS/SSE" + ); + assert!( + TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"), + "本地文件夹 bootstrap transport 应直接选择 /api/local-folder/events" + ); + assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch")); assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS .contains("fetch(window.location.href, { headers: { accept: 'text/html' } })")); assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index cf17589c..0fbf194a 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -2011,12 +2011,20 @@ body { text-decoration: none !important; } -.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"] { +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"], +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing { color: #9f1239 !important; background: #fff1f2 !important; box-shadow: inset 0 0 0 1px #fecdd3 !important; } +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"], +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized { + color: #92400e !important; + background: #fffbeb !important; + box-shadow: inset 0 0 0 1px #fde68a !important; +} + .document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-row::before, .document-shell .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]::before { content: "" !important; @@ -2029,10 +2037,16 @@ body { box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12) !important; } -.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before { +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-missing="true"]::before, +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-missing::before { background: #e11d48 !important; } +.document-shell .editor-surface .ProseMirror a[data-mnote-attachment-unauthorized="true"]::before, +.document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-unauthorized::before { + background: #d97706 !important; +} + .document-shell .editor-surface .ProseMirror a.mnote-uploaded-attachment-word::before { background: #4f7df3; } diff --git a/scripts/task435-local-folder-watch-no-reload-smoke.js b/scripts/task435-local-folder-watch-no-reload-smoke.js index a1e1e9b7..e3d707a5 100644 --- a/scripts/task435-local-folder-watch-no-reload-smoke.js +++ b/scripts/task435-local-folder-watch-no-reload-smoke.js @@ -73,6 +73,18 @@ async function waitForVisibleText(page, text) { ); } +async function waitForAnyPageTreeNode(page, documentIdPrefix) { + await page.waitForFunction( + (prefix) => Array.from(document.querySelectorAll("#sidebar-tree-root .tree-row[data-node-id]")) + .some((row) => { + const id = row.getAttribute("data-node-id") || ""; + return id.startsWith(prefix); + }), + documentIdPrefix, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function waitForGone(page, selector) { await page.locator(selector).waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }); } @@ -84,6 +96,30 @@ async function waitForFileTreeRow(page, rowId) { }); } +async function expandFileTreeFolder(page, rowId) { + const rowSelector = `.tree-row[data-row-id="${rowId}"]`; + await page.locator(rowSelector).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + (selector) => { + const row = document.querySelector(selector); + return !!row; + }, + rowSelector, + { timeout: UI_TIMEOUT_MS }, + ); + const expanded = await page.locator(rowSelector).getAttribute("aria-expanded").catch(() => null); + if (expanded === "true") return; + await page.locator(`${rowSelector} [data-rust-action="toggle"]`).click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + (selector) => { + const row = document.querySelector(selector); + return row && row.getAttribute("aria-expanded") === "true"; + }, + rowSelector, + { timeout: UI_TIMEOUT_MS }, + ); +} + async function waitForFileTreeRowGone(page, rowId) { await waitForGone(page, `.tree-row[data-row-id="${rowId}"]`); } @@ -108,15 +144,18 @@ async function waitForPageTreeNodeGone(page, documentId) { async function waitForLocalFolderWatchProjectionApplied(page) { await page.waitForFunction( - () => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") === "projection", + () => { + const value = document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") || ""; + return value === "projection" || value === "watch_batch"; + }, { timeout: UI_TIMEOUT_MS }, ); const appliedValue = await page.evaluate( () => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"), ); assert( - appliedValue === "projection", - `local_folder watch 应通过 projection 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`, + appliedValue === "projection" || appliedValue === "watch_batch", + `local_folder 变更应通过事件驱动 projection/watch_batch 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`, ); } @@ -159,15 +198,19 @@ async function run() { } }); - const steps = []; + const steps = []; try { await quickLogin(page); await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await waitForVisibleText(page, "Local Root"); - await waitForVisibleText(page, "stable"); - await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), { - timeout: UI_TIMEOUT_MS, - }).catch(() => {}); + await waitForAnyPageTreeNode(page, "local-md:"); + await waitForPageTreeNode(page, localMdDocumentId("docs/stable.md")); + await page.waitForFunction( + () => { + const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || ""; + return transport === "local-folder-events"; + }, + { timeout: UI_TIMEOUT_MS }, + ); await page.waitForTimeout(100); navigationEvents.length = 0; @@ -195,10 +238,15 @@ async function run() { await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await waitForFileTreeRow(page, "local:folder:docs"); + await expandFileTreeFolder(page, "local:folder:docs"); await waitForFileTreeRow(page, "local:asset:docs/stable-asset.txt"); - await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), { - timeout: UI_TIMEOUT_MS, - }).catch(() => {}); + await page.waitForFunction( + () => { + const transport = document.documentElement.getAttribute("data-mnote-tree-live-transport") || ""; + return transport === "local-folder-events"; + }, + { timeout: UI_TIMEOUT_MS }, + ); await page.waitForTimeout(100); navigationEvents.length = 0; diff --git a/scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js b/scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js index 49251ec8..862143ca 100644 --- a/scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js +++ b/scripts/task473-local-folder-trash-restore-no-refresh-focus-gap-smoke.js @@ -351,7 +351,7 @@ async function main() { evidence.restoreFocusDebug.localStorageKeysAfterFiletreeLoad = await page.evaluate(() => ( Object.keys(window.localStorage || {}).filter((key) => key.indexOf("mnote") >= 0) )).catch(() => []); - // 等待轮询检测到变化后文件行重现(最长 1200ms polling + 180ms debounce) + // 等待事件驱动 tree live 刷新后文件行重现 try { await waitForFileTreeRow(page, assetRowIdStr); evidence.fileReappearsInFiletree = true; @@ -367,11 +367,11 @@ async function main() { } assert.equal( evidence.fileReappearsInFiletree, true, - `restore 后 filetree 应通过轮询显示文件行(row-id: ${assetRowIdStr})`, + `restore 后 filetree 应通过事件驱动刷新显示文件行(row-id: ${assetRowIdStr})`, ); evidence.steps.push({ step: 8, - label: "回到 filetree 页面后文件行重现(轮询更新)", + label: "回到 filetree 页面后文件行重现(事件刷新)", ok: evidence.fileReappearsInFiletree, }); diff --git a/scripts/task494-filetree-lazy-loading-dedup-smoke.js b/scripts/task494-filetree-lazy-loading-dedup-smoke.js index 2b8871f4..289b6ea6 100644 --- a/scripts/task494-filetree-lazy-loading-dedup-smoke.js +++ b/scripts/task494-filetree-lazy-loading-dedup-smoke.js @@ -112,7 +112,6 @@ async function main() { let staleScopeChildrenRequests = 0; let scopedRootProjectionRequests = 0; let workspaceRootProjectionRequests = 0; - let forceChangingWatchRevision = false; let localWatchRevision = 0; page.on("console", (message) => { @@ -140,24 +139,6 @@ async function main() { } await route.continue(); }); - await page.route("**/api/tree/local-folder-watch**", async (route) => { - if (!forceChangingWatchRevision) { - await route.continue(); - return; - } - localWatchRevision += 1; - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - ok: true, - result: { - revision: `forced-watch-${localWatchRevision}`, - }, - }), - }); - }); - try { await waitForHttpOk(`${baseUrl}/health`, 60_000); const authResponse = await context.request.fetch(`${baseUrl}/api/auth`, { @@ -239,30 +220,40 @@ async function main() { relativePath, expanded: row.getAttribute("aria-expanded"), selected: row.getAttribute("data-selected"), + focused: row.getAttribute("data-focused"), active: row.getAttribute("data-active"), } : null; }; + const firstMarkdownPath = firstParent + "/" + firstParent.split("/").pop() + ".md"; + const secondMarkdownPath = secondParent + "/" + secondParent.split("/").pop() + ".md"; return { first: readRow(firstParent), second: readRow(secondParent), - firstMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(firstParent + "/" + firstParent.split("/").pop() + ".md")}"]`)), - secondMarkdownVisible: Boolean(document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="${CSS.escape(secondParent + "/" + secondParent.split("/").pop() + ".md")}"]`)), + firstMarkdown: readRow(firstMarkdownPath), + secondMarkdown: readRow(secondMarkdownPath), }; }, { firstParent: firstCreatedParentPath, secondParent: secondCreatedParentPath }); assert.equal( - createExpansionState.first?.expanded, - "false", - `连续新建页面不应把上一个页面包目录自动展开: ${JSON.stringify(createExpansionState)}`, + createExpansionState.second?.expanded, + "true", + `新建页面后当前页面包目录应展开,避免焦点落到父文件夹: ${JSON.stringify(createExpansionState)}`, ); assert.equal( - createExpansionState.second?.expanded, - "false", - `连续新建页面不应为了选中内部 md 而展开当前页面包目录: ${JSON.stringify(createExpansionState)}`, + createExpansionState.secondMarkdown?.selected, + "true", + `新建页面后应选中内部 Markdown 行: ${JSON.stringify(createExpansionState)}`, + ); + assert.equal( + createExpansionState.secondMarkdown?.focused, + "true", + `新建页面后文件树焦点应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`, + ); + assert.equal( + createExpansionState.secondMarkdown?.active, + "true", + `新建页面后 active 应落在内部 Markdown 行: ${JSON.stringify(createExpansionState)}`, ); - assert.equal(createExpansionState.firstMarkdownVisible, false, `上一个页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); - assert.equal(createExpansionState.secondMarkdownVisible, false, `当前页面包内部 md 不应闪现/常驻: ${JSON.stringify(createExpansionState)}`); - assert.equal(createExpansionState.second?.active, "true", `当前页面包目录应承接 active 状态: ${JSON.stringify(createExpansionState)}`); const scopedUrl = new URL(baseUrl); if (workspaceId) scopedUrl.searchParams.set("workspaceId", workspaceId); @@ -341,13 +332,27 @@ async function main() { "scoped 文件树收到非 scope root snapshot 不应兜底重拉 scope 根 projection", ); const scopedRootRequestsBeforeCoarseWatch = scopedRootProjectionRequests; - forceChangingWatchRevision = true; - await page.waitForTimeout(1600); - forceChangingWatchRevision = false; + localWatchRevision += 1; + await page.evaluate((revision) => { + window.dispatchEvent(new CustomEvent("tree:local-folder-watch-batch", { + detail: { + payload: { + schema: "mnote.local_folder_watch_batch.v1", + sourceKind: "local_folder", + revision: `forced-watch-${revision}`, + affectedParents: [{ relativePath: "", reason: "coarse-watch" }], + changedPaths: [{ relativePath: "design", kind: "Modify(Name(Both))" }], + eventKinds: ["Modify(Name(Both))"], + fallbackResync: false, + }, + }, + })); + }, localWatchRevision); + await page.waitForTimeout(300); assert.equal( scopedRootProjectionRequests, scopedRootRequestsBeforeCoarseWatch, - "scoped 文件树收到 coarse local-folder-watch revision 不应重拉 scope 根 projection", + "scoped 文件树收到 coarse local-folder 事件不应重拉 scope 根 projection", ); const rowSelector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path="design/03-rust-web"]'; diff --git a/scripts/task503-local-pptx-upload-filetree-open-smoke.js b/scripts/task503-local-pptx-upload-filetree-open-smoke.js index 7b30dd5b..e1f5bee3 100644 --- a/scripts/task503-local-pptx-upload-filetree-open-smoke.js +++ b/scripts/task503-local-pptx-upload-filetree-open-smoke.js @@ -106,6 +106,50 @@ async function waitForEditorLinks(page, expectedCount) { }))); } +function assertStandardMarkdownAttachmentLinks(links, label) { + assert(links.length > 0, `${label}: 应存在编辑器附件链接`); + for (const link of links) { + assert(!link.href.includes("/office-preview"), `${label}: 附件 href 不应持久写入 /office-preview: ${JSON.stringify(link)}`); + assert(!link.href.includes("/api/local-folder/files/open"), `${label}: 附件 href 不应持久写入本地 open API: ${JSON.stringify(link)}`); + assert(/^(?:\.{1,2}\/|[^:/?#]+(?:\/|$))/.test(link.href), `${label}: 附件 href 应为 Markdown 相对链接: ${JSON.stringify(link)}`); + } +} + +function assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, label) { + const markdown = fs.readFileSync(path.join(root, relativePath), "utf8"); + assert(!markdown.includes("/office-preview"), `${label}: Markdown 原文不应包含 /office-preview\n${markdown}`); + assert(!markdown.includes("/api/local-folder/files/open"), `${label}: Markdown 原文不应包含本地 open API\n${markdown}`); + assert(/\[[^\]]+\.pptx\]\(\.\/[^)]+\.pptx\)/i.test(markdown), `${label}: Markdown 原文应包含标准相对 PPTX 链接\n${markdown}`); + return markdown; +} + +async function readPageAggregate(page) { + return await page.evaluate(() => { + const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); + if (!script) return null; + try { + return JSON.parse(script.textContent || "null"); + } catch (_error) { + return null; + } + }); +} + +async function assertAttachmentRefProjection(page, expectedCount, label) { + const aggregate = await readPageAggregate(page); + const refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : []; + assert(refs.length >= expectedCount, `${label}: attachmentRefs 数量不足: ${JSON.stringify(refs)}`); + const pptxRefs = refs.filter((ref) => /\.pptx$/i.test(String(ref.rawHref || ""))); + assert(pptxRefs.length >= expectedCount, `${label}: attachmentRefs 应包含 PPTX: ${JSON.stringify(refs)}`); + for (const ref of pptxRefs.slice(0, expectedCount)) { + assert(/^\.\//.test(String(ref.rawHref || "")), `${label}: rawHref 应保持同目录 Markdown 相对链接: ${JSON.stringify(ref)}`); + assert.equal(ref.kind, "pageLocal", `${label}: 同目录附件应为 pageLocal: ${JSON.stringify(ref)}`); + assert.equal(ref.openKind, "office", `${label}: PPTX openKind 应为 office: ${JSON.stringify(ref)}`); + assert.equal(ref.authorized, true, `${label}: 当前授权 root 内附件应 authorized=true: ${JSON.stringify(ref)}`); + } + return pptxRefs; +} + async function assertNoConflict(page, label) { await page.waitForTimeout(1500); const state = await page.evaluate(() => { @@ -403,23 +447,29 @@ async function main() { await uploadAttachmentViaSlash(page, PPTX_PATH); const firstLinks = await waitForEditorLinks(page, 1); + assertStandardMarkdownAttachmentLinks(firstLinks, "first-editor-upload"); const firstConflictState = await assertNoConflict(page, "first-editor-upload"); assertLocalUploadSaveVersions(result, 1); - result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState }); + const firstMarkdown = assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, "first-editor-upload"); + result.states.push({ step: "first-editor-upload", links: firstLinks, conflictState: firstConflictState, markdown: firstMarkdown }); await typeLineAfterUpload(page, "第一份附件后的正文"); const afterFirstEditConflictState = await assertNoConflict(page, "after-first-upload-edit"); result.states.push({ step: "after-first-upload-edit", conflictState: afterFirstEditConflictState }); await uploadAttachmentViaSlash(page, PPTX_PATH); const secondLinks = await waitForEditorLinks(page, 2); + assertStandardMarkdownAttachmentLinks(secondLinks, "second-editor-upload"); const secondConflictState = await assertNoConflict(page, "second-editor-upload"); assertLocalUploadSaveVersions(result, 2); + const secondMarkdown = assertMarkdownSourceUsesStandardAttachmentLinks(root, relativePath, "second-editor-upload"); await delay(1800); const editorRows = await waitForFileTreeAssetRows(page, 2); - result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, fileTreeRows: editorRows }); + result.states.push({ step: "second-editor-upload", links: secondLinks, conflictState: secondConflictState, markdown: secondMarkdown, fileTreeRows: editorRows }); assert(editorRows.every((row) => row.localRelativePath), `文件树上传行缺少本地路径: ${JSON.stringify(editorRows)}`); await openDocument(page, root, relativePath); - await waitForEditorLinks(page, 2); + assertStandardMarkdownAttachmentLinks(await waitForEditorLinks(page, 2), "reopen-editor-upload"); + const attachmentRefs = await assertAttachmentRefProjection(page, 2, "reopen-editor-upload"); + result.states.push({ step: "reopen-editor-upload", attachmentRefs }); await clickEditorPptxLink(page, context, fileName); await activatePrimaryPageTab(page); await clickEditorPptxLink(page, context, fileName.replace(/\.pptx$/i, "-1.pptx")); diff --git a/scripts/task506-local-markdown-attachment-ref-matrix-smoke.js b/scripts/task506-local-markdown-attachment-ref-matrix-smoke.js new file mode 100644 index 00000000..4092d383 --- /dev/null +++ b/scripts/task506-local-markdown-attachment-ref-matrix-smoke.js @@ -0,0 +1,498 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000); +const PDF_PATH = process.env.MNOTE_TASK506_PDF_PATH || "/home/lix/Downloads/ao2c06124.pdf"; +const DOCX_PATH = process.env.MNOTE_TASK506_DOCX_PATH || "/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx"; +const PNG_PATH = process.env.MNOTE_TASK506_PNG_PATH || "/home/lix/Downloads/image.png"; +const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task506-local-markdown-attachment-ref-matrix-smoke"); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] + .find((candidate) => fs.existsSync(candidate)); + +function fileUrl(localPath) { + return `file://${localPath.split(path.sep).map((part, index) => ( + index === 0 ? "" : encodeURIComponent(part) + )).join("/")}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${Buffer.from(relativePath, "utf8") + .toString("hex") + .replace(/../g, (hex) => { + const code = Number.parseInt(hex, 16); + const ch = String.fromCharCode(code); + return /[A-Za-z0-9._-]/.test(ch) ? ch : `~${hex.toUpperCase()}`; + })}`; +} + +function workspaceId(ownerId) { + return `local-ws:${ownerId}:task506`; +} + +function documentUrl(root, relativePath) { + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", fileUrl(root)); + url.searchParams.set("treeView", "filetree"); + return url.toString(); +} + +function writeWorkspaceManifest(root, ownerId) { + const metadataDir = path.join(root, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: workspaceId(ownerId), + ownerId, + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "asset_upload"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function quickLogin(page) { + await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" }); + if (await quickLoginButton.count()) { + await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined); + } +} + +async function ensureTestUser(context) { + const signIn = await context.request.fetch(`${BASE_URL}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: "mnote.e2e@example.com", + password: TEST_PASSWORD, + flow: "signIn", + }, + }, + }, + }); + if (signIn.ok()) return; + const signUp = await context.request.fetch(`${BASE_URL}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: "mnote.e2e@example.com", + username: "mnote-e2e", + name: "mnote-e2e", + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert(signUp.ok(), `测试用户创建失败: ${signUp.status()} ${await signUp.text()}`); +} + +async function openDocument(page, root, relativePath) { + await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function uploadAttachmentViaSlash(page, filePath) { + await activatePrimaryPageTab(page).catch(() => undefined); + const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first(); + await editor.click({ timeout: UI_TIMEOUT_MS }); + await page.keyboard.press("End").catch(() => undefined); + await page.keyboard.type("/"); + const item = page.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]').first(); + await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }), + item.click({ timeout: UI_TIMEOUT_MS }), + ]); + await fileChooser.setFiles(filePath); +} + +async function activatePrimaryPageTab(page) { + await page.waitForFunction( + () => typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === "function", + null, + { timeout: UI_TIMEOUT_MS }, + ).catch(() => undefined); + await page.evaluate(() => { + if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === "function") { + window.__mnoteDocumentPaneRuntime.activatePageTab({ paneRole: "primary" }); + } + }).catch(() => undefined); + const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first(); + if (await pageTab.isVisible({ timeout: 1000 }).catch(() => false)) { + await pageTab.click({ timeout: UI_TIMEOUT_MS }); + } + await page.waitForFunction(() => !new URL(location.href).searchParams.get("resourceTab"), null, { timeout: UI_TIMEOUT_MS / 2 }); + await page.waitForFunction(() => { + const pagePanel = document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]'); + const resourceHost = document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]'); + const pageTab = document.querySelector('[data-mnote-main-tab="page"][data-pane-role="primary"]'); + return pagePanel instanceof HTMLElement + && pagePanel.hidden === false + && (!(resourceHost instanceof HTMLElement) || resourceHost.hidden === true) + && (!(pageTab instanceof HTMLElement) || pageTab.getAttribute("aria-selected") === "true"); + }, null, { timeout: UI_TIMEOUT_MS / 2 }); + await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({ + state: "visible", + timeout: UI_TIMEOUT_MS, + }); +} + +async function waitForNoUploadSaveError(page, label) { + await page.waitForTimeout(1200); + const state = await page.evaluate(() => ({ + lastSaveError: document.documentElement.getAttribute("data-mnote-last-upload-save-error") || "", + conflictVisible: Boolean(document.querySelector('[data-testid="mnote-editor-conflict-panel"]')), + inserted: document.documentElement.getAttribute("data-mnote-last-upload-inserted") || "", + })); + assert.equal(state.lastSaveError, "", `${label}: 上传保存不应失败 ${JSON.stringify(state)}`); + assert.equal(state.conflictVisible, false, `${label}: 上传不应触发冲突 ${JSON.stringify(state)}`); + return state; +} + +async function readAggregate(page) { + return await page.evaluate(() => { + const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); + if (!script) return null; + return JSON.parse(script.textContent || "null"); + }); +} + +async function bypassRuntimeAssetCache(context) { + await context.route("**/api/mnote-browser-runtime/**", async (route) => { + const headers = { + ...route.request().headers(), + "cache-control": "no-cache", + pragma: "no-cache", + }; + await route.continue({ headers }); + }); +} + +function readMarkdown(root, relativePath) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function assertNoRuntimeHref(markdown, label) { + assert(!markdown.includes("/office-preview"), `${label}: Markdown 不应包含 /office-preview\n${markdown}`); + assert(!markdown.includes("/api/local-folder/files/open"), `${label}: Markdown 不应包含本地 open API\n${markdown}`); +} + +async function editorAttachmentLinks(page) { + return await page.evaluate(() => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')) + .map((link) => ({ + text: link.textContent || "", + href: link.getAttribute("href") || "", + className: link.getAttribute("class") || "", + missing: link.getAttribute("data-mnote-attachment-missing") || "", + unauthorized: link.getAttribute("data-mnote-attachment-unauthorized") || "", + ariaLabel: link.getAttribute("aria-label") || "", + }))); +} + +async function clickAttachmentLink(page, text) { + await activatePrimaryPageTab(page); + const link = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a').filter({ hasText: text }).first(); + await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + for (let attempt = 0; attempt < 4; attempt += 1) { + const visible = await link.evaluate((node) => { + if (!node) return false; + const scrollParent = (() => { + let current = node.parentElement; + while (current && current !== document.body && current !== document.documentElement) { + if (current.scrollHeight > current.clientHeight + 8) return current; + current = current.parentElement; + } + return document.scrollingElement || document.documentElement; + })(); + if (typeof node.scrollIntoView === "function") { + node.scrollIntoView({ block: "center", inline: "nearest" }); + } + const box = node.getBoundingClientRect(); + const targetY = window.innerHeight / 2; + const deltaY = box.y + box.height / 2 - targetY; + if (Math.abs(deltaY) > 8 && scrollParent) { + scrollParent.scrollTop += deltaY; + } + const next = node.getBoundingClientRect(); + return next.y >= 0 && next.y + next.height <= window.innerHeight; + }); + if (visible) break; + await page.waitForTimeout(120); + } + await link.evaluate((node) => { + if (node && typeof node.scrollIntoView === "function") { + node.scrollIntoView({ block: "center", inline: "nearest" }); + } + }); + await page.waitForTimeout(120); + const rect = await page.evaluate((needle) => { + const candidates = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')); + const node = candidates.find((item) => (item.textContent || "").includes(needle)); + if (!node) return null; + const box = node.getBoundingClientRect(); + const top = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2); + return { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + viewportHeight: window.innerHeight, + className: node.getAttribute("class") || "", + href: node.getAttribute("href") || "", + topTag: top ? top.tagName : "", + topText: top ? (top.textContent || "").slice(0, 80) : "", + topClass: top instanceof HTMLElement ? (top.getAttribute("class") || "") : "", + }; + }, text); + assert(rect, `找不到可点击附件链接: ${text}`); + assert( + rect.y >= 0 && rect.y + rect.height <= rect.viewportHeight, + `附件链接未滚动到可点击视口内: ${text} ${JSON.stringify(rect)}`, + ); + await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2); + await page.waitForTimeout(180); + return await page.evaluate((needle) => ({ + needle, + blocked: document.documentElement.getAttribute("data-mnote-attachment-open-blocked") || "", + pageHidden: document.querySelector('[data-mnote-page-tab-panel][data-pane-role="primary"]')?.hidden ?? null, + resourceHidden: document.querySelector('[data-mnote-resource-tab-host][data-pane-role="primary"]')?.hidden ?? null, + activePageTab: document.querySelector('[data-mnote-main-tab="page"][data-pane-role="primary"]')?.getAttribute("aria-selected") || "", + }), text); +} + +async function waitForActiveResource(page, text, label) { + await page.waitForFunction( + (needle) => { + const tab = document.querySelector('[data-testid="document-resource-tab"][data-active="true"], [data-mnote-resource-tab][data-active="true"]'); + const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])'); + const frame = panel?.querySelector?.('iframe.mnote-resource-tab-frame'); + const frameSrc = frame instanceof HTMLIFrameElement ? decodeURIComponent(frame.getAttribute("src") || "") : ""; + return (tab && (tab.textContent || "").includes(needle)) + || (panel && (panel.textContent || "").includes(needle)) + || frameSrc.includes(needle); + }, + text, + { timeout: UI_TIMEOUT_MS }, + ); + await page.screenshot({ path: path.join(OUTPUT_DIR, `${label}.png`), fullPage: false }); +} + +async function createAdminGrant(root, targetUserId) { + const adminContext = await chromium.launch({ + headless: true, + executablePath: CHROMIUM_EXECUTABLE_PATH, + }).then((browser) => browser.newContext({ + extraHTTPHeaders: { + "x-mnote-actor-id": "mnote-e2e", + "x-mnote-actor-type": "admin", + }, + }).then((context) => ({ browser, context }))); + try { + const response = await adminContext.context.request.post(`${BASE_URL}/api/admin/access-policy/grants`, { + data: { + userId: targetUserId, + rootUri: fileUrl(root), + permission: "read", + recursive: true, + capabilities: [], + }, + }); + assert(response.ok(), `创建外部授权失败: ${response.status()} ${await response.text()}`); + } finally { + await adminContext.context.close().catch(() => undefined); + await adminContext.browser.close().catch(() => undefined); + } +} + +async function main() { + for (const fixture of [PDF_PATH, DOCX_PATH, PNG_PATH]) { + assert(fs.existsSync(fixture), `测试文件不存在: ${fixture}`); + } + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-attachments-")); + const allowedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-allowed-")); + const deniedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task506-denied-")); + const relativePath = "中文页面/中文页面.md"; + const docDir = path.join(root, "中文页面"); + fs.mkdirSync(docDir, { recursive: true }); + writeWorkspaceManifest(root, "mnote-e2e"); + fs.writeFileSync(path.join(root, relativePath), "# Attachment Matrix\n\n正文\n", "utf8"); + fs.writeFileSync(path.join(docDir, "manual.pdf"), Buffer.from("%PDF-1.4\n% manual\n", "utf8")); + fs.writeFileSync(path.join(docDir, "manual.png"), fs.readFileSync(PNG_PATH)); + fs.mkdirSync(path.join(root, "assets"), { recursive: true }); + fs.writeFileSync(path.join(root, "assets", "bad.pdf"), Buffer.from("%PDF-1.4\n% bad\n", "utf8")); + fs.writeFileSync(path.join(allowedRoot, "allowed.pdf"), Buffer.from("%PDF-1.4\n% allowed\n", "utf8")); + fs.writeFileSync(path.join(deniedRoot, "denied.pdf"), Buffer.from("%PDF-1.4\n% denied\n", "utf8")); + + const browser = await chromium.launch({ + headless: process.env.HEADFUL !== "1", + executablePath: CHROMIUM_EXECUTABLE_PATH, + }); + const context = await browser.newContext({ + viewport: { width: 1366, height: 900 }, + extraHTTPHeaders: { + "x-mnote-actor-id": "mnote-e2e", + "x-mnote-actor-type": "user", + }, + }); + await bypassRuntimeAssetCache(context); + let page = await context.newPage(); + const result = { root, allowedRoot, deniedRoot, states: [], console: [], pageErrors: [] }; + const attachPageDiagnostics = (targetPage) => { + targetPage.on("console", (message) => result.console.push({ type: message.type(), text: message.text() })); + targetPage.on("pageerror", (error) => result.pageErrors.push(String(error && error.stack || error))); + }; + attachPageDiagnostics(page); + + try { + await ensureTestUser(context); + await quickLogin(page); + await createAdminGrant(allowedRoot, "mnote-e2e"); + await openDocument(page, root, relativePath); + + await uploadAttachmentViaSlash(page, PDF_PATH); + await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-last-upload-saved") === "true", null, { timeout: UI_TIMEOUT_MS }); + await waitForNoUploadSaveError(page, "pdf-upload"); + const pdfName = path.basename(PDF_PATH); + const immediatePdfLinks = await editorAttachmentLinks(page); + assert( + immediatePdfLinks.some((link) => link.text.includes(pdfName) && link.missing !== "true"), + `中文页面上传 PDF 后刷新前不应标记 missing: ${JSON.stringify(immediatePdfLinks)}`, + ); + result.states.push({ step: "click-uploaded-pdf-before-reload", click: await clickAttachmentLink(page, pdfName) }); + await waitForActiveResource(page, pdfName, "00-uploaded-pdf-open-before-reload"); + const immediatePdfOpenUrl = await page.evaluate(() => { + const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])'); + const frame = panel?.querySelector?.('iframe.mnote-resource-tab-frame'); + return frame instanceof HTMLIFrameElement ? decodeURIComponent(frame.getAttribute("src") || "") : ""; + }); + assert( + !immediatePdfOpenUrl.includes("~E"), + `中文 local-md 解码后不应把 ~E6... 写入 PDF open URL: ${immediatePdfOpenUrl}`, + ); + await openDocument(page, root, relativePath); + await uploadAttachmentViaSlash(page, PNG_PATH); + await waitForNoUploadSaveError(page, "png-upload"); + await openDocument(page, root, relativePath); + await uploadAttachmentViaSlash(page, DOCX_PATH); + await waitForNoUploadSaveError(page, "docx-upload"); + + let markdown = readMarkdown(root, relativePath); + assertNoRuntimeHref(markdown, "after-uploads"); + assert(/\[[^\]]+\.pdf\]\(\.\/[^)]+\.pdf\)/i.test(markdown), `应保存标准 PDF 相对链接\n${markdown}`); + assert(/\[[^\]]+\.docx\]\(\.\/[^)]+\.docx\)/i.test(markdown), `应保存标准 DOCX 相对链接\n${markdown}`); + assert(/!\[[^\]]*\]\(\.\/[^)]+\.png\)/i.test(markdown), `应保存标准 PNG 图片链接\n${markdown}`); + assert(fs.existsSync(path.join(docDir, path.basename(PDF_PATH))), "PDF 应写入 md 同目录"); + assert(fs.existsSync(path.join(docDir, path.basename(DOCX_PATH))), "DOCX 应写入 md 同目录"); + assert(fs.existsSync(path.join(docDir, path.basename(PNG_PATH))), "PNG 应写入 md 同目录"); + assert(!fs.existsSync(path.join(docDir, "Loose", path.basename(PDF_PATH))), "非 bundle md 不应写入同名子目录"); + + const manualAppend = [ + "", + `[同目录手写](./manual.pdf)`, + `![手写图片](./manual.png)`, + `[授权外部](file://${path.join(allowedRoot, "allowed.pdf")})`, + `[未授权外部](file://${path.join(deniedRoot, "denied.pdf")})`, + `[不支持上级目录](../assets/bad.pdf)`, + "", + ].join("\n"); + fs.appendFileSync(path.join(root, relativePath), manualAppend, "utf8"); + await openDocument(page, root, relativePath); + const aggregate = await readAggregate(page); + const refs = Array.isArray(aggregate?.body?.attachmentRefs) ? aggregate.body.attachmentRefs : []; + const byLabel = (label) => refs.find((ref) => ref.label === label); + assert.equal(byLabel("同目录手写")?.authorized, true, `同目录手写应授权: ${JSON.stringify(refs)}`); + assert.equal(byLabel("授权外部")?.authorized, true, `授权外部应授权: ${JSON.stringify(refs)}`); + assert.equal(byLabel("未授权外部")?.authorized, false, `未授权外部应阻断: ${JSON.stringify(refs)}`); + assert.equal(byLabel("不支持上级目录")?.kind, "unknown", `../assets 应为 unknown: ${JSON.stringify(refs)}`); + + result.states.push({ step: "click-manual", click: await clickAttachmentLink(page, "同目录手写") }); + await waitForActiveResource(page, "manual.pdf", "01-manual-pdf-open"); + result.states.push({ step: "click-authorized-external", click: await clickAttachmentLink(page, "授权外部") }); + await waitForActiveResource(page, "allowed.pdf", "02-authorized-file-open"); + result.states.push({ step: "click-unauthorized-external", click: await clickAttachmentLink(page, "未授权外部") }); + await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-attachment-open-blocked") === "unauthorized", null, { timeout: UI_TIMEOUT_MS }); + await page.screenshot({ path: path.join(OUTPUT_DIR, "03-unauthorized-blocked.png"), fullPage: false }); + + fs.unlinkSync(path.join(docDir, pdfName)); + await openDocument(page, root, relativePath); + await page.waitForFunction( + (name) => Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')) + .some((link) => (link.textContent || "").includes(name) + && (link.getAttribute("data-mnote-attachment-missing") === "true" + || link.classList.contains("mnote-uploaded-attachment-missing"))), + pdfName, + { timeout: UI_TIMEOUT_MS }, + ); + result.states.push({ step: "click-missing-uploaded-pdf", click: await clickAttachmentLink(page, pdfName) }); + await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-attachment-open-blocked") === "missing", null, { timeout: UI_TIMEOUT_MS }); + await page.screenshot({ path: path.join(OUTPUT_DIR, "04-missing-blocked.png"), fullPage: false }); + + await openDocument(page, root, relativePath); + const finalLinks = await editorAttachmentLinks(page); + markdown = readMarkdown(root, relativePath); + assertNoRuntimeHref(markdown, "final-markdown"); + result.states.push({ step: "final", markdown, refs, links: finalLinks }); + fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ ok: true, ...result }, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ ok: true, resultPath: RESULT_PATH, root, allowedRoot, deniedRoot }, null, 2)); + } catch (error) { + const diagnostics = await page.evaluate(() => ({ + url: location.href, + blocked: document.documentElement.getAttribute("data-mnote-attachment-open-blocked") || "", + links: Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a')).map((link) => ({ + text: link.textContent || "", + href: link.getAttribute("href") || "", + className: link.getAttribute("class") || "", + missing: link.getAttribute("data-mnote-attachment-missing") || "", + unauthorized: link.getAttribute("data-mnote-attachment-unauthorized") || "", + })), + aggregate: (() => { + const script = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); + return script ? JSON.parse(script.textContent || "null") : null; + })(), + })).catch((err) => ({ diagnosticsError: String(err) })); + await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined); + fs.writeFileSync( + RESULT_PATH, + `${JSON.stringify({ ok: false, ...result, diagnostics, error: String(error && error.stack || error) }, null, 2)}\n`, + "utf8", + ); + console.error(JSON.stringify({ ok: false, resultPath: RESULT_PATH, root, diagnostics, error: String(error && error.stack || error) }, null, 2)); + process.exitCode = 1; + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +});