fix: 收口批次D并修复本地垃圾箱清空
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
## 最终回复格式
|
||||
|
||||
- 修改文件列表
|
||||
- 收敛了哪些重复分支
|
||||
- 运行过的命令和结果
|
||||
- 未完成项/风险
|
||||
@@ -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
|
||||
```
|
||||
|
||||
如果你新增了更精确测试名,也请单独运行对应测试。
|
||||
|
||||
## 最终回复格式
|
||||
|
||||
- 修改文件列表
|
||||
- 标题优先级实现方式
|
||||
- 运行过的命令和结果
|
||||
- 未完成项/风险
|
||||
@@ -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`
|
||||
+115
@@ -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/`。
|
||||
+3
-2
@@ -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 仍保留 [ ] 项的状态
|
||||
|
||||
+11
-3
@@ -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。
|
||||
|
||||
@@ -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#"<article class="mnote-trash-row" data-trash-row="local" data-trash-entry-id="{entry_id}" data-resource-kind="{kind}" data-filetree-row-id="{filetree_row_id}">
|
||||
<div class="mnote-trash-row-main">
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -4449,7 +4449,8 @@ pub(crate) fn render_local_file_tree_html(
|
||||
active_row_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
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"]
|
||||
|
||||
@@ -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);"));
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user