fix: 收口批次D并修复本地垃圾箱清空

This commit is contained in:
lix-2026
2026-05-21 15:45:11 +08:00
parent e709c0705c
commit dc18ec0a60
14 changed files with 525 additions and 72 deletions
@@ -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`
@@ -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 D2Markdown 标题优先级收口
### 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/`
@@ -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 仍保留 [ ] 项的状态
@@ -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-21Reasonix Worker C 对照了 `openConvexAssetFromFileTree``openEditorAttachmentDetail``openEditorAttachmentNewWindow``openEditorAttachmentEditTab` 的重复 local Office 打开分支,并提交了“新增 `openLocalOfficeFileInActiveTab` 辅助函数”的计划。
- 2026-05-21Worker C 进程停留在计划提交阶段,没有生成 `final.md`,也没有实际修改 `layout.rs` / `task463`;本轮不把 5-26 checklist 标记为完成。
- 后续建议:若继续推进 5-26,应先用 Codex 本地复核 helper 边界,再小步改 `layout.rs`,重点保持“正文附件弹窗编辑进 main resource tab、正文附件新窗口编辑进浏览器窗口、md/text/code 继续走 tiptap”三条已验证行为不回退。
- 2026-05-22Batch 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。
+19 -16
View File
@@ -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");
+5 -2
View File
@@ -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"]
+29 -25
View File
@@ -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({