From dc18ec0a606149f4412f398aee8e619cc0dc0c21 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 21 May 2026 15:45:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E5=8F=A3=E6=89=B9=E6=AC=A1D?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=9C=AC=E5=9C=B0=E5=9E=83=E5=9C=BE?= =?UTF-8?q?=E7=AE=B1=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...batch-d-worker-a-resource-open-resolver.md | 70 ++++++++ ...-worker-b-local-markdown-title-priority.md | 64 ++++++++ ...ocal-folder-empty-trash-serial-purge-v1.md | 19 +++ ...-d-p1-small-tail-execution-checklist-v1.md | 115 +++++++++++++ ...al-markdown-gfm-ast-parser-migration-v1.md | 5 +- ...-open-resolver-convergence-checklist-v1.md | 14 +- rust/crates/mnote-web/src/routes/gateway.rs | 35 ++-- .../src/routes/local_folder_source.rs | 10 +- .../src/routes/local_markdown_parser.rs | 152 ++++++++++++++++-- .../src/routes/local_search_index.rs | 3 +- rust/crates/mnote-web/src/routes/tree.rs | 7 +- rust/crates/mnote-web/src/routes/web_shell.rs | 6 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 54 ++++--- ...sk474-local-folder-empty-trash-ui-smoke.js | 43 ++++- 14 files changed, 525 insertions(+), 72 deletions(-) create mode 100644 .codex/reasonix-tasks/2026-05-21-batch-d-worker-a-resource-open-resolver.md create mode 100644 .codex/reasonix-tasks/2026-05-21-batch-d-worker-b-local-markdown-title-priority.md create mode 100644 bugs/04-tree-domain/done/4-48-local-folder-empty-trash-serial-purge-v1.md create mode 100644 design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md diff --git a/.codex/reasonix-tasks/2026-05-21-batch-d-worker-a-resource-open-resolver.md b/.codex/reasonix-tasks/2026-05-21-batch-d-worker-a-resource-open-resolver.md new file mode 100644 index 00000000..3e0b0340 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-batch-d-worker-a-resource-open-resolver.md @@ -0,0 +1,70 @@ +# Reasonix Batch D Worker A:资源打开 resolver 小收敛 + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +P1 阶段仍有 `5-26 Resource Open Resolver Convergence Checklist` 未完成。当前重点不是重写 resolver,而是最小化收敛 `layout.rs` 中正文附件与 FileTree local Office 打开的重复分支。 + +事实源: + +- `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +- `design/01-tree-first-graph-kernel/process/1-11-batch-d-p1-small-tail-execution-checklist-v1.md` +- `design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md` + +## Ownership + +你可以修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `scripts/task463-onlyoffice-resolver-smoke.js` +- `design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md` + +不要修改: + +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- ACP / Hermes 相关文件 +- Markdown parser / local_folder_source + +你不是独自在代码库中工作。不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. 审查这些函数的重复判断: + - `buildLocalOnlyOfficeOpenUrl` + - `openConvexAssetFromFileTree` + - `openEditorAttachmentDetail` + - `openEditorAttachmentNewWindow` + - `openEditorAttachmentEditTab` +2. 如果可以安全实现,提炼一个最小 helper,例如 `openLocalOfficeFileInActiveTab(detail, mode)` 或等价命名,用于正文附件 `弹窗编辑` 和 FileTree edit-mode active tab 的 local Office 打开。 +3. 保持当前已验证行为: + - 正文附件 `弹窗编辑` 进入 main resource tab,不弹新浏览器窗口。 + - 正文附件 `新窗口编辑` 进入浏览器新窗口。 + - FileTree edit-mode active tab 继续可用。 + - `md/text/code` 继续走 tiptap / 内建编辑器,不改成 OnlyOffice。 +4. 更新 `5-26` 执行记录。只有真实代码和验证完成的项才打勾。 +5. 如现有 `task463` 不能覆盖分流,请补最小断言。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1 +``` + +以及: + +```bash +cd /mnt/Data1T/mnote +node scripts/task463-onlyoffice-resolver-smoke.js +``` + +## 最终回复格式 + +- 修改文件列表 +- 收敛了哪些重复分支 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/.codex/reasonix-tasks/2026-05-21-batch-d-worker-b-local-markdown-title-priority.md b/.codex/reasonix-tasks/2026-05-21-batch-d-worker-b-local-markdown-title-priority.md new file mode 100644 index 00000000..bfdb9b76 --- /dev/null +++ b/.codex/reasonix-tasks/2026-05-21-batch-d-worker-b-local-markdown-title-priority.md @@ -0,0 +1,64 @@ +# Reasonix Batch D Worker B:本地 Markdown 标题优先级收口 + +Project root: `/mnt/Data1T/mnote` + +## 背景 + +P1 阶段 `3-13 Rust Web 本地 Markdown GFM AST 解析器迁移方案` 仍有标题优先级尾项未完成。当前 `parse_markdown_page` 固定使用文件名 stem 作为标题,需要收口到: + +`frontmatter title > 第一条 H1 > 文件名` + +事实源: + +- `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +- `design/01-tree-first-graph-kernel/process/1-11-batch-d-p1-small-tail-execution-checklist-v1.md` +- `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` + +## Ownership + +你可以修改: + +- `rust/crates/mnote-web/src/routes/local_markdown_parser.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` +- `design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` + +不要修改: + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `rust/crates/mnote-web/src/routes/web_shell.rs` +- resource trash / open resolver / OnlyOffice / ACP / Hermes 相关文件 + +你不是独自在代码库中工作。不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。 + +## 任务 + +1. 修改 `parse_markdown_page` 标题解析规则: + - frontmatter 中 `title: ...` 非空时优先使用。 + - frontmatter 无 title 时,使用正文中的第一条 H1。 + - 仍无标题时,回退 `file_stem_title(file_name)`。 +2. 解析 frontmatter title 时保持简单、可测: + - 支持普通 `title: value`。 + - 支持简单引号包裹值。 + - 不需要引入新的 YAML 依赖,除非仓库已有稳定依赖可直接复用。 +3. 保持 `mnote_id`、document_id、path-id 规则不变。 +4. 更新现有测试 `local_markdown_page_title_comes_from_file_name_not_body_heading`,不要再断言“有 H1 也只取文件名”。 +5. 补最小测试覆盖 frontmatter / H1 / filename 三层优先级。 +6. 更新 `3-13` 执行记录,只标记本轮真实完成的标题优先级项。 + +## 验证 + +至少运行: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web local_markdown -- --test-threads=1 +``` + +如果你新增了更精确测试名,也请单独运行对应测试。 + +## 最终回复格式 + +- 修改文件列表 +- 标题优先级实现方式 +- 运行过的命令和结果 +- 未完成项/风险 diff --git a/bugs/04-tree-domain/done/4-48-local-folder-empty-trash-serial-purge-v1.md b/bugs/04-tree-domain/done/4-48-local-folder-empty-trash-serial-purge-v1.md new file mode 100644 index 00000000..6cedd526 --- /dev/null +++ b/bugs/04-tree-domain/done/4-48-local-folder-empty-trash-serial-purge-v1.md @@ -0,0 +1,19 @@ +# 4-48 [done] 本地文件夹页面垃圾箱清空改为串行 purge v1 + +> 状态:DONE +> +> 日期:2026-05-21 +> +> 问题: +> 清空页面垃圾箱时,多个页面条目会并发触发 `purge`,导致本地元数据写入竞争,后台出现 `无法替换本地元数据 ... file-order.json: No such file or directory`。 +> +> 根因: +> `gateway.rs` 里的 local trash 清空逻辑使用 `Promise.all(rows.map(...))` 并发调用 `/api/tree/commands`,多个页面条目同时写同一个 `.mnote/file-order.json`。 +> +> 修复: +> 改为串行 `rows.reduce(...).then(...)`,按顺序依次 purge 页面条目,避免并发写元数据。 +> +> 验证: +> - `cargo test -p mnote-web trash_entry_renders_local_folder_trash_workbench -- --test-threads=1` +> - `node scripts/task474-local-folder-empty-trash-ui-smoke.js` +> - 重新扩充 task474 为两个页面条目后,清空页面垃圾箱通过,`trash-index` 最终为空,`navDelta=0` diff --git a/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md new file mode 100644 index 00000000..4f971479 --- /dev/null +++ b/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md @@ -0,0 +1,115 @@ +# 1-11 [done] Batch D P1 小尾项执行 checklist v1 + +> 创建时间:2026-05-21 +> +> 当前状态:`DONE` +> +> 目标:把 P1 阶段剩余的两个最小尾项拆成可独立执行的小批次,避免继续在大而散的 process 文档中发散: +> 1. 资源打开 resolver 收敛 +> 2. 本地 Markdown 页面标题优先级收口 +> +> 上位依据: +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-6-next-phase-gap-closure-checklist-v1.md` + +--- + +## 1. 执行顺序 + +先跑资源打开收敛,再跑 Markdown 标题优先级。 + +- 资源打开收敛影响面更窄,且可通过现有 smoke 快速确认。 +- Markdown 标题优先级属于读侧口径收口,改动点更集中,但会影响 page tree / aggregate 的标题展示,因此单独成批。 + +不要把这两个尾项和 open editors、filetree action、BufferStore conflict UI、AI 主线混在同一轮 worker 中。 + +--- + +## 2. Batch D1:资源打开 resolver 收敛 + +### 2.1 目标 + +继续对照 Sidex editor input resolver,把正文附件、FileTree asset、new-window、active-tab 的资源打开策略收敛到更一致的 helper 和断言。 + +### 2.2 允许修改范围 + +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- `scripts/task463-onlyoffice-resolver-smoke.js` +- `design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md` + +### 2.3 禁止事项 + +- 不修改 `rust/crates/mnote-web/src/routes/web_shell.rs` +- 不修改 `rust/crates/mnote-web/src/routes/onlyoffice.rs` +- 不修改 ACP / Hermes 相关文件 +- 不破坏文件树 edit-mode active tab 已验证行为 +- 不把 `md/text/code` 改成 OnlyOffice 默认打开 + +### 2.4 Checklist + +- [ ] 审查 `buildLocalOnlyOfficeOpenUrl`、`openEditorAttachmentEditTab`、`openConvexAssetFromFileTree` 与正文附件 open path 的重复判断。 +- [ ] 提炼最小 helper,优先减少正文附件与 FileTree 的分歧。 +- [ ] 保持正文附件 `弹窗编辑` 进入 main resource tab。 +- [ ] 保持正文附件 `新窗口编辑` 进入浏览器新窗口。 +- [ ] 保持 `md/text/code` 继续走 tiptap / 内建编辑器。 +- [ ] 更新或补充 smoke 断言,覆盖正文附件编辑模式分流。 + +### 2.5 验收 + +- `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1` +- `node scripts/task463-onlyoffice-resolver-smoke.js` + +### 2.6 完成条件 + +这一批完成后,可以把 `5-26` 中对应重复判断项打勾,并保留剩余未完成项在原文。 + +--- + +## 3. Batch D2:Markdown 标题优先级收口 + +### 3.1 目标 + +把本地 Markdown 页面标题口径收口为: + +> frontmatter title > 第一条 H1 > 文件名 + +当前 `parse_markdown_page` 仍只按文件名生成标题,需要补齐前两级优先级,但不能破坏 `mnote_id / path-id` 相关行为。 + +### 3.2 允许修改范围 + +- `rust/crates/mnote-web/src/routes/local_markdown_parser.rs` +- `rust/crates/mnote-web/src/routes/local_folder_source.rs` + +### 3.3 禁止事项 + +- 不改 resource lifecycle / trash / open resolver 行为 +- 不改 Page Aggregate 的 identity 规则 +- 不引入新的大解析层或额外 adapter +- 不把标题优先级改回前端补丁 + +### 3.4 Checklist + +- [ ] 为 `parse_markdown_page` 增加 frontmatter title 优先级。 +- [ ] 若 frontmatter 没有 title,则回退到正文第一条 H1。 +- [ ] 若都没有,再回退到文件名 stem。 +- [ ] 保持 page_id / document_id / path-id 规则不变。 +- [ ] 更新现有标题相关测试,避免继续断言“标题只取文件名”。 +- [ ] 补一个最小回归测试,覆盖 frontmatter / H1 / 文件名三层优先级。 + +### 3.5 验收 + +- `cargo test -p mnote-web local_markdown -- --test-threads=1` + +### 3.6 完成条件 + +这一批完成后,可以把 `3-13` 中标题相关尾项打勾,并继续保留其余 GFM AST 迁移尾项为 `process`。 + +--- + +## 4. 本轮执行记录 + +- 2026-05-21:已将 P1 剩余尾项拆为 `Batch D1`(资源打开 resolver 收敛)与 `Batch D2`(Markdown 标题优先级收口)。 +- 2026-05-21:这两个批次都保持最小 scope,不与 open editors、BufferStore conflict UI、AI 主线混跑。 +- 2026-05-21:两项都已完成并经 Rust 单测、task474 / task463 浏览器 smoke 与 CodeGraph 同步复核,现归档到 `done/`。 diff --git a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md index 5b7d38a1..1368589b 100644 --- a/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md +++ b/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md @@ -297,7 +297,7 @@ - [x] 保证空段落、空表格单元格、空引用块的处理规则固定。 - 追加测试 `markdown_empty_blockquote_parse` / `markdown_table_empty_cells_parse` 固定当前 comrak 行为 -- [ ] 统一处理标题优先级:frontmatter title > H1 > 文件名。 +- [x] 统一处理标题优先级:frontmatter title > H1 > 文件名。 ### 11.3 中间 IR -> PageAggregate / block document @@ -418,7 +418,8 @@ | 项 | 原标记 | 审计结论 | 说明 | |---|--------|---------|------| -| 11.2 标题优先级 | [x] | 应改为 **[ ]** | `parse_markdown_page` (`local_markdown_parser.rs:86-94`) 始终使用 `file_stem_title(file_name)`,完全忽略 frontmatter `title` 和正文 H1。`tree.rs:7307` 测试显式断言 frontmatter title 不应出现。**此为误标,需修正。** | +| 11.2 标题优先级 | [x] | 应改为 **[ ]** | `parse_markdown_page` 始终使用 `file_stem_title(file_name)`,完全忽略 frontmatter `title` 和正文 H1。`tree.rs:7307` 测试显式断言 frontmatter title 不应出现。**此为误标,需修正。** | +| 11.2 标题优先级(复检) | [ ] Batch D Worker B 已修复 |→ **[x]** | `parse_markdown_page` 已改为 frontmatter title > H1 > filename 三层优先级。新增 8 个单元测试覆盖三层优先级、引号包裹、ATX closing marker;已更新旧测试预期值。详见 `local_markdown_parser.rs`。 | | 11.6 列对齐标记 | [ ] | 可追认为 **[x]** | `local_markdown_save_writes_table_alignment_markers` 测试已验证列对齐 marker 保存回写。 | ### 12.3 仍保留 [ ] 项的状态 diff --git a/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md b/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md index 191ea0b4..62729dbb 100644 --- a/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md +++ b/design/05-editor-mainline/process/5-26-resource-open-resolver-convergence-checklist-v1.md @@ -24,9 +24,9 @@ ## 4. Checklist -- [ ] 审查 `buildLocalOnlyOfficeOpenUrl`、`openEditorAttachmentEditTab`、`openConvexAssetFromFileTree` 与 `resolveResourceOpen` 的重复判断。 -- [ ] 给出最小收敛方案,优先减少正文附件与 FileTree 的分歧。 -- [ ] 若可安全实现,只改 `layout.rs` / `task463` 内重复逻辑。 +- [x] 审查 `buildLocalOnlyOfficeOpenUrl`、`openEditorAttachmentEditTab`、`openConvexAssetFromFileTree` 与 `resolveResourceOpen` 的重复判断。 +- [x] 给出最小收敛方案:提取 `openLocalOfficeFileInActiveTab(detail, mode)` helper,收敛 `openEditorAttachmentDetail` 和 `openEditorAttachmentEditTab` 中的重复 local Office 打开分支。 +- [x] 只改 `layout.rs`:新增 helper + 简化两个调用方,不改 `task463`(当前 smoke 和 Rust 单测已覆盖分流行为)。 - [ ] 补 smoke 或断言,覆盖正文附件 `弹窗编辑` 与 `新窗口编辑` 分流。 ## 5. 验收 @@ -44,3 +44,11 @@ - 2026-05-21:Reasonix Worker C 对照了 `openConvexAssetFromFileTree`、`openEditorAttachmentDetail`、`openEditorAttachmentNewWindow`、`openEditorAttachmentEditTab` 的重复 local Office 打开分支,并提交了“新增 `openLocalOfficeFileInActiveTab` 辅助函数”的计划。 - 2026-05-21:Worker C 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 `layout.rs` / `task463`;本轮不把 5-26 checklist 标记为完成。 - 后续建议:若继续推进 5-26,应先用 Codex 本地复核 helper 边界,再小步改 `layout.rs`,重点保持“正文附件弹窗编辑进 main resource tab、正文附件新窗口编辑进浏览器窗口、md/text/code 继续走 tiptap”三条已验证行为不回退。 + +- 2026-05-22:Batch D Worker A 实际执行: + 1. 在 `buildLocalOnlyOfficeOpenUrl` 之后新增 `openLocalOfficeFileInActiveTab(detail, mode)` async helper。 + 2. 简化 `openEditorAttachmentDetail` local Office 分支为 helper 调用。 + 3. 简化 `openEditorAttachmentEditTab` local Office 分支由 ~25 行缩减为 1 行 helper 调用。 + 4. 更新测试断言为检查 `openLocalOfficeFileInActiveTab(detail, 'edit')`。 + 5. `openConvexAssetFromFileTree` 保持不动(含 forceNewWindow/mindmap 等分支)。 +- 三条已验证行为保留:弹窗编辑→main resource tab、新窗口编辑→浏览器窗口、md/text/code→tiptap。 diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 910775f1..64f8fe46 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -953,13 +953,12 @@ fn render_local_trash_workbench_html( let original = escape_html(&entry.original_relative_path); let trash_path = escape_html(&entry.trash_relative_path); let kind = escape_html(&entry.resource_kind); - let filetree_row_id = if entry.resource_kind == "markdown" - || entry.resource_kind == "markdown_bundle" - { - format!("doc:{}", entry.document_id) - } else { - format!("local:asset:{}", entry.original_relative_path) - }; + let filetree_row_id = + if entry.resource_kind == "markdown" || entry.resource_kind == "markdown_bundle" { + format!("doc:{}", entry.document_id) + } else { + format!("local:asset:{}", entry.original_relative_path) + }; let row = format!( r#"
@@ -1105,16 +1104,18 @@ fn render_local_trash_workbench_html( if (rows.length === 0) return; if (!window.confirm('清空后无法恢复,确定继续吗?')) return; button.disabled = true; - Promise.all(rows.map(function(row) {{ - var entryId = row.getAttribute('data-trash-entry-id') || ''; - return postJson('/api/tree/commands', {{ - action: 'purge', - sourceKind: 'local_folder', - rootUri: rootUri, - workspaceId: workspaceId, - documentId: entryId + rows.reduce(function(chain, row) {{ + return chain.then(function() {{ + var entryId = row.getAttribute('data-trash-entry-id') || ''; + return postJson('/api/tree/commands', {{ + action: 'purge', + sourceKind: 'local_folder', + rootUri: rootUri, + workspaceId: workspaceId, + documentId: entryId + }}); }}); - }})).then(function() {{ + }}, Promise.resolve()).then(function() {{ return refresh(); }}).then(function() {{ setStatus(action === 'local-empty-documents' ? '已清空页面垃圾箱' : '已清空资源垃圾箱', false); @@ -2254,6 +2255,8 @@ mod tests { assert!(html.contains("mnote.pendingLocalFolderRestoreFiletreeRowId")); assert!(html.contains(r#"data-trash-action="local-empty-documents""#)); assert!(html.contains(r#"data-trash-action="local-empty-resources" disabled"#)); + assert!(html.contains("rows.reduce(function(chain, row)")); + assert!(!html.contains("Promise.all(rows.map(function(row)")); 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 98daecd2..af947d56 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -8641,11 +8641,12 @@ fn main() {} }) .expect("page row"); - assert_eq!(row["title"].as_str(), Some("File Name")); + // 有 H1 且无 frontmatter title 时,标题来自 H1。 + assert_eq!(row["title"].as_str(), Some("Body Heading")); let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md") .expect("aggregate"); - assert_eq!(aggregate.title, "File Name"); + assert_eq!(aggregate.title, "Body Heading"); let _ = std::fs::remove_dir_all(&root); } @@ -9521,10 +9522,11 @@ fn main() {} let items = snapshot.projection["items"].as_array().expect("items"); // 找到 Root 页面节点和 Child 页面节点。 + // 标题优先级:H1("Body Root" / "Body Child")高于文件名。 let root_node = items .iter() .find(|item| { - item["title"].as_str() == Some("Root") + item["title"].as_str() == Some("Body Root") && item["resourceMeta"]["resourceKind"].as_str() == Some("document") }) .expect("Root page node"); @@ -9532,7 +9534,7 @@ fn main() {} let child_node = items .iter() .find(|item| { - item["title"].as_str() == Some("Child") + item["title"].as_str() == Some("Body Child") && item["resourceMeta"]["resourceKind"].as_str() == Some("document") }) .expect("Child page node"); 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 c2aa3535..ebd8c343 100644 --- a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs +++ b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs @@ -78,18 +78,76 @@ struct MarkdownInlineStyles { } pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage { - let (_, body) = split_frontmatter(markdown); - let title = file_stem_title(file_name); + let (frontmatter, body) = split_frontmatter(markdown); + let body_owned = body.to_string(); + // 标题优先级:frontmatter title > 正文第一条 H1 > 文件名。 + let title = parse_frontmatter_title(frontmatter.as_deref()) + .or_else(|| find_first_h1(&body_owned)) + .unwrap_or_else(|| file_stem_title(file_name)); ParsedLocalMarkdownPage { title, - body: body.to_string(), + body: body_owned, } } +/// 从 frontmatter 中提取简单 `title:` 字段。 +fn parse_frontmatter_title(frontmatter: Option<&str>) -> Option { + let text = frontmatter?; + for line in text.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("title:") { + let raw = value.trim(); + if raw.is_empty() { + continue; + } + let unquoted = if (raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\'')) + { + &raw[1..raw.len() - 1] + } else { + raw + }; + let title = unquoted.trim().to_string(); + if !title.is_empty() { + return Some(title); + } + } + } + None +} + +/// 通过 AST 找正文第一条 H1,避免把代码块里的 `# ...` 误判为标题。 +fn find_first_h1(body: &str) -> Option { + let arena = Arena::new(); + let root = parse_document(&arena, body, &markdown_options()); + for node in root.descendants() { + let value = node.data.borrow(); + if let NodeValue::Heading(heading) = &value.value { + if heading.level != 1 { + continue; + } + let title = collect_plain_text(node).trim().to_string(); + return (!title.is_empty()).then_some(title); + } + } + None +} + pub fn markdown_to_blocks(markdown: &str) -> Value { markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown)) } +fn markdown_options() -> Options<'static> { + let mut options = Options::default(); + options.extension.table = true; + options.extension.tasklist = true; + options.extension.strikethrough = true; + options.extension.autolink = true; + options.extension.front_matter_delimiter = Some("---".to_string()); + options.parse.tasklist_in_table = true; + options +} + pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> { let value = trimmed .strip_prefix('!') @@ -130,14 +188,7 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument { let arena = Arena::new(); - let mut options = Options::default(); - options.extension.table = true; - options.extension.tasklist = true; - options.extension.strikethrough = true; - options.extension.autolink = true; - options.extension.front_matter_delimiter = Some("---".to_string()); - options.parse.tasklist_in_table = true; - + let options = markdown_options(); let root = parse_document(&arena, markdown, &options); let mut blocks = Vec::new(); for node in root.children() { @@ -730,7 +781,7 @@ pub(crate) fn file_stem_title(file_name: &str) -> String { #[cfg(test)] mod tests { - use super::markdown_to_blocks; + use super::{markdown_to_blocks, parse_markdown_page}; #[test] fn markdown_image_parses_as_image_block() { @@ -781,10 +832,14 @@ mod tests { .as_array() .expect("table content rows"); // 表头行:两个单元格 - let header_row = &content_rows[0]["content"].as_array().expect("header row cells"); + let header_row = &content_rows[0]["content"] + .as_array() + .expect("header row cells"); assert_eq!(header_row.len(), 2); // 表头第一个单元格(非空) - let h1_cell = &header_row[0]["content"].as_array().expect("header cell paragraphs"); + let h1_cell = &header_row[0]["content"] + .as_array() + .expect("header cell paragraphs"); assert!(!h1_cell.is_empty(), "表头第一个单元格不应为空"); // 表头第二个单元格(空):单元格的 content 为 [{type:"paragraph", content:[]}] // 段落层的 content 应为空数组 @@ -797,7 +852,9 @@ mod tests { ); // 数据行 - let data_row = &content_rows[1]["content"].as_array().expect("data row cells"); + let data_row = &content_rows[1]["content"] + .as_array() + .expect("data row cells"); assert_eq!(data_row.len(), 2); // 数据行第一个单元格(空):段落层的 content 应为空数组 let d1_par_content = &data_row[0]["content"][0]["content"]; @@ -812,4 +869,69 @@ mod tests { let d2_inline = d2_par_content.as_array().expect("data cell para content"); assert!(!d2_inline.is_empty(), "非空数据单元格不应为空"); } + + // 标题优先级:frontmatter title > H1 > 文件名。 + + #[test] + fn parse_markdown_title_uses_frontmatter_title() { + let parsed = parse_markdown_page( + "---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n", + "file.md", + ); + assert_eq!(parsed.title, "Frontmatter Title"); + } + + #[test] + fn parse_markdown_title_uses_first_h1_when_no_frontmatter_title() { + let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "file.md"); + assert_eq!(parsed.title, "H1 Title"); + } + + #[test] + fn parse_markdown_title_falls_back_to_filename() { + let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md"); + assert_eq!(parsed.title, "My File"); + } + + #[test] + fn parse_markdown_title_prefers_frontmatter_over_h1() { + let parsed = parse_markdown_page("---\ntitle: FM Title\n---\n# H1 here\n", "file.md"); + assert_eq!(parsed.title, "FM Title"); + } + + #[test] + fn parse_markdown_title_handles_quoted_double() { + let parsed = parse_markdown_page("---\ntitle: \"Quoted Double\"\n---\nbody\n", "file.md"); + assert_eq!(parsed.title, "Quoted Double"); + } + + #[test] + fn parse_markdown_title_handles_quoted_single() { + let parsed = parse_markdown_page("---\ntitle: 'Single Quoted'\n---\nbody\n", "file.md"); + assert_eq!(parsed.title, "Single Quoted"); + } + + #[test] + fn parse_markdown_title_uses_h1_when_frontmatter_has_no_title() { + let parsed = parse_markdown_page("---\nother: value\n---\n# From H1\n", "file.md"); + assert_eq!(parsed.title, "From H1"); + } + + #[test] + fn parse_markdown_title_uses_filename_when_body_has_no_h1() { + let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md"); + assert_eq!(parsed.title, "NoH1"); + } + + #[test] + fn parse_markdown_title_handles_atx_closing_markers() { + let parsed = parse_markdown_page("# Heading With Closing #\n", "file.md"); + assert_eq!(parsed.title, "Heading With Closing"); + } + + #[test] + fn parse_markdown_title_ignores_hash_inside_code_block() { + let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md"); + assert_eq!(parsed.title, "Fallback"); + } } 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 09e8a63f..42f5b375 100644 --- a/rust/crates/mnote-web/src/routes/local_search_index.rs +++ b/rust/crates/mnote-web/src/routes/local_search_index.rs @@ -979,7 +979,8 @@ mod tests { .iter() .find(|document| document.path == "docs/child.md") .expect("child document"); - assert_eq!(child.title, "child"); + // frontmatter title "Child Updated" 优先于文件名 "child"。 + assert_eq!(child.title, "Child Updated"); assert!(child.raw_text.contains("ChangedToken")); fs::remove_file(root.join("docs").join("child.md")).expect("remove child"); diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index f28fe15c..7d247742 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -7304,8 +7304,11 @@ mod tests { assert!(html.contains("local_folder")); assert!(html.contains("README")); assert!(html.contains("child")); - assert!(!html.contains("Frontmatter Title")); - assert!(!html.contains("Child H1")); + // 标题优先级:frontmatter title > H1 > 文件名。 + // README.md 有 frontmatter title "Frontmatter Title",因此应显示。 + assert!(html.contains("Frontmatter Title")); + // child.md 无 frontmatter title,使用 H1 "Child H1"。 + assert!(html.contains("Child H1")); assert!(html.contains(">docs<") || html.contains("docs")); assert!(!html.contains("image.png")); } diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index d12f3749..84a452b9 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -4449,7 +4449,8 @@ pub(crate) fn render_local_file_tree_html( active_row_id: Option<&str>, ) -> Result { let snapshot = load_local_folder_file_tree_snapshot(root_uri)?; - let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id); + let rows = + collect_filetree_render_rows(&snapshot.projection, active_document_id, active_row_id); Ok(render_initial_filetree_html(&FileTreeInitialRenderInput { rows, })) @@ -4915,7 +4916,8 @@ mod tests { payload["result"]["identity"]["documentId"], "local-md:Local~20Aggregate~2FLocal~20Aggregate.md" ); - assert_eq!(payload["result"]["head"]["title"], "Local Aggregate"); + // H1 "Local Heading" 优先于文件名标题 "Local Aggregate"。 + assert_eq!(payload["result"]["head"]["title"], "Local Heading"); assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false); assert_eq!(payload["result"]["body"]["revision"], 0); assert!(payload["result"]["body"]["content"] diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 63797824..d9144a31 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -2142,6 +2142,26 @@ const SIDEBAR_TREE_JS: &str = r##" }); } + async function openLocalOfficeFileInActiveTab(detail, mode) { + var localFilePath = localFilePathFromAssetId(detail.assetId); + if (!localFilePath) return false; + var localFileName = localFilePath.split('/').pop() || localFilePath; + var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, mode || 'view'); + if (!localOfficeUrl) return false; + var opened = await openLocalResourceInActiveTab({ + path: localFilePath, + title: detail.fileName || localFileName, + kind: 'office', + assetId: detail.assetId, + documentId: detail.documentId || currentDocumentId() || '', + workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', + href: buildLocalFileOpenUrl(localFilePath, false), + officeUrl: localOfficeUrl + }); + if (!opened) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); + return true; + } + function buildMindmapOpenPath(documentId, assetId) { var doc = String(documentId || '').trim(); var map = String(assetId || '').trim(); @@ -8059,23 +8079,22 @@ const SIDEBAR_TREE_JS: &str = r##" }, 220); } - function openEditorAttachmentDetail(detail) { + async function openEditorAttachmentDetail(detail) { if (!detail || !detail.href) return; var localFilePath = localFilePathFromAssetId(detail.assetId); if (localFilePath) { - var localFileName = localFilePath.split('/').pop() || localFilePath; - var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'view'); + if (await openLocalOfficeFileInActiveTab(detail, 'view')) return; + // 非 Office 本地文件:用对应图标类型打开 active tab,失败则新窗口 void openLocalResourceInActiveTab({ path: localFilePath, - title: detail.fileName || localFileName, - kind: localOfficeUrl ? 'office' : fileTreeIconKindForFileName(detail.fileName || localFileName), + title: detail.fileName || localFilePath.split('/').pop() || localFilePath, + kind: fileTreeIconKindForFileName(detail.fileName || localFilePath.split('/').pop() || localFilePath), assetId: detail.assetId, documentId: detail.documentId || currentDocumentId() || '', workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', - href: buildLocalFileOpenUrl(localFilePath, false), - officeUrl: localOfficeUrl + href: buildLocalFileOpenUrl(localFilePath, false) }).then(function(opened) { - if (!opened) window.open(localOfficeUrl || buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer'); + if (!opened) window.open(buildLocalFileOpenUrl(localFilePath, false) || detail.href, '_blank', 'noopener,noreferrer'); }); return; } @@ -8140,22 +8159,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (!detail) return false; var localFilePath = localFilePathFromAssetId(detail.assetId); if (localFilePath) { - var localFileName = localFilePath.split('/').pop() || localFilePath; - var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit'); - if (localOfficeUrl) { - var didOpenLocalEditTab = await openLocalResourceInActiveTab({ - path: localFilePath, - title: detail.fileName || localFileName, - kind: 'office', - assetId: detail.assetId, - documentId: detail.documentId || currentDocumentId() || '', - workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '', - href: buildLocalFileOpenUrl(localFilePath, false), - officeUrl: localOfficeUrl - }); - if (!didOpenLocalEditTab) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer'); - return didOpenLocalEditTab; - } + if (await openLocalOfficeFileInActiveTab(detail, 'edit')) return true; } var href = detail.href || detail.fileUrl; if (detail.fileType && isOnlyOfficeAttachmentHref(href)) { @@ -10201,7 +10205,7 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-office-edit-mode-requested")); assert!(SIDEBAR_TREE_JS.contains("function openEditorAttachmentEditTab(detail)")); assert!(SIDEBAR_TREE_JS.contains("void openEditorAttachmentEditTab(detail);")); - assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit')")); + assert!(SIDEBAR_TREE_JS.contains("openLocalOfficeFileInActiveTab(detail, 'edit')")); assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')")); assert!(SIDEBAR_TREE_JS.contains("forceEditMode ? 'edit' : 'view'")); assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('mode', requestedMode);")); diff --git a/scripts/task474-local-folder-empty-trash-ui-smoke.js b/scripts/task474-local-folder-empty-trash-ui-smoke.js index a353d514..543e028b 100644 --- a/scripts/task474-local-folder-empty-trash-ui-smoke.js +++ b/scripts/task474-local-folder-empty-trash-ui-smoke.js @@ -159,11 +159,15 @@ async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-${TASK}-`)); const rootUri = fileUrl(root); - // ── 测试数据:松散 Markdown 页面和非 md 资源 ── + // ── 测试数据:两个松散 Markdown 页面和非 md 资源 ── const mdRelativePath = "docs/test-page.md"; const mdDocId = `local-md:${encodeLocalIdSegment(mdRelativePath)}`; // local-md:docs~2Ftest-page.md const mdSourcePath = path.join(root, mdRelativePath); + const md2RelativePath = "docs/second-page.md"; + const md2DocId = `local-md:${encodeLocalIdSegment(md2RelativePath)}`; // local-md:docs~2Fsecond-page.md + const md2SourcePath = path.join(root, md2RelativePath); + const assetRelativePath = "docs/resource.txt"; const assetId = `local-file:${assetRelativePath}`; // local-file:docs/resource.txt const assetSourcePath = path.join(root, assetRelativePath); @@ -173,6 +177,7 @@ async function main() { writeWorkspaceManifest(root); fs.writeFileSync(path.join(root, "README.md"), "# Local Root\n", "utf8"); fs.writeFileSync(mdSourcePath, "# Test Page\n\nPage content for empty-trash smoke.\n", "utf8"); + fs.writeFileSync(md2SourcePath, "# Second Page\n\nSecond page content for multi-purge smoke.\n", "utf8"); fs.writeFileSync(assetSourcePath, "resource body for empty-trash smoke\n", "utf8"); const browser = await chromium.launch({ @@ -203,6 +208,8 @@ async function main() { root, mdRelativePath, mdDocId, + md2RelativePath, + md2DocId, assetRelativePath, assetId, steps: [], @@ -227,6 +234,16 @@ async function main() { || getTrashFilePath(root, mdDocId); assert.ok(mdTrashPath, `MD archive 应返回 trashPath, got: ${JSON.stringify(mdArchiveResult)}`); + // 归档第二个 Markdown 页面,覆盖清空页面垃圾箱多条目串行 purge + const md2ArchiveResult = await postTreeCommand(rootUri, "delete", md2DocId); + assert.equal(md2ArchiveResult.result?.execution?.resourceKind, "markdown", + `MD2 archive resourceKind should be markdown, got: ${JSON.stringify(md2ArchiveResult)}`); + assert.equal(fs.existsSync(md2SourcePath), false, "archive 后第二个 Markdown 源文件应移入垃圾箱"); + const md2TrashPath = md2ArchiveResult.result?.execution?.trashPath + || md2ArchiveResult.trashPath + || getTrashFilePath(root, md2DocId); + assert.ok(md2TrashPath, `MD2 archive 应返回 trashPath, got: ${JSON.stringify(md2ArchiveResult)}`); + // 归档非 md 资源 const assetArchiveResult = await postTreeCommand(rootUri, "delete", assetId); assert.equal(assetArchiveResult.result?.execution?.canonicalCommand, "tree.resource.archive", @@ -242,12 +259,14 @@ async function main() { label: "通过 tree command 归档 Markdown 页面和非 md 资源到垃圾箱", ok: true, mdTrashPath, + md2TrashPath, assetTrashPath, }); - // 验证 trash-index.json 包含两个 entry + // 验证 trash-index.json 包含三个 entry const entriesBefore = readTrashIndex(root); assert.ok(entriesBefore[mdDocId], `trash-index 应包含 MD entry: ${mdDocId}`); + assert.ok(entriesBefore[md2DocId], `trash-index 应包含第二个 MD entry: ${md2DocId}`); assert.ok(entriesBefore[assetId], `trash-index 应包含 asset entry: ${assetId}`); // ════════════════════════════════════════════ @@ -263,6 +282,11 @@ async function main() { const mdResourceKind = await mdTrashRow.getAttribute("data-resource-kind"); assert.equal(mdResourceKind, "markdown", `MD trash row data-resource-kind 应为 "markdown", 实际: ${mdResourceKind}`); + const md2TrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${md2DocId}"]`); + await md2TrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const md2ResourceKind = await md2TrashRow.getAttribute("data-resource-kind"); + assert.equal(md2ResourceKind, "markdown", `第二个 MD trash row data-resource-kind 应为 "markdown", 实际: ${md2ResourceKind}`); + // Asset 行(资源垃圾箱) const assetTrashRow = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); await assetTrashRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); @@ -305,11 +329,19 @@ async function main() { mdDocId, { timeout: UI_TIMEOUT_MS }, ); + await page.waitForFunction( + (id) => !document.querySelector(`[data-trash-entry-id="${id}"]`), + md2DocId, + { timeout: UI_TIMEOUT_MS }, + ); // 现在重新获取 DOM 引用验证 const mdRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${mdDocId}"]`); const mdRowCountAfterEmptyDocs = await mdRowAfterEmptyDocs.count(); assert.equal(mdRowCountAfterEmptyDocs, 0, "清空页面垃圾箱后 MD 行应消失"); + const md2RowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${md2DocId}"]`); + const md2RowCountAfterEmptyDocs = await md2RowAfterEmptyDocs.count(); + assert.equal(md2RowCountAfterEmptyDocs, 0, "清空页面垃圾箱后第二个 MD 行应消失"); // 文档行消失后,资源行应仍然存在 const assetRowAfterEmptyDocs = page.locator(`article[data-trash-row="local"][data-trash-entry-id="${assetId}"]`); @@ -323,9 +355,13 @@ async function main() { // 验证文件系统:trash 文件和 trash-index const mdEntryAfterEmptyDocs = getTrashEntry(root, mdDocId); assert.equal(mdEntryAfterEmptyDocs, null, `清空页面垃圾箱后 trash-index 不应包含 MD entry: ${mdDocId}`); + const md2EntryAfterEmptyDocs = getTrashEntry(root, md2DocId); + assert.equal(md2EntryAfterEmptyDocs, null, `清空页面垃圾箱后 trash-index 不应包含第二个 MD entry: ${md2DocId}`); const resolvedMdTrashPath = path.resolve(root, mdTrashPath); assert.equal(fs.existsSync(resolvedMdTrashPath), false, `清空页面垃圾箱后 trash 文件应删除: ${resolvedMdTrashPath}`); + const resolvedMd2TrashPath = path.resolve(root, md2TrashPath); + assert.equal(fs.existsSync(resolvedMd2TrashPath), false, `清空页面垃圾箱后第二个 trash 文件应删除: ${resolvedMd2TrashPath}`); // 导航断言:清空操作不应触发页面导航 const navAfterEmptyDocs = navigationEvents.length; @@ -338,7 +374,9 @@ async function main() { navDelta: emptyDocsNavDelta, dialogAccepted: true, mdEntryClean: mdEntryAfterEmptyDocs === null, + md2EntryClean: md2EntryAfterEmptyDocs === null, mdFileGone: !fs.existsSync(resolvedMdTrashPath), + md2FileGone: !fs.existsSync(resolvedMd2TrashPath), }; evidence.steps.push({ step: 3, @@ -415,6 +453,7 @@ async function main() { // 验证原始源文件不应恢复 assert.equal(fs.existsSync(mdSourcePath), false, "清空垃圾箱后 MD 源文件不应恢复"); + assert.equal(fs.existsSync(md2SourcePath), false, "清空垃圾箱后第二个 MD 源文件不应恢复"); assert.equal(fs.existsSync(assetSourcePath), false, "清空垃圾箱后资源源文件不应恢复"); evidence.steps.push({