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
+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);"));