batch E: close tail checks and refresh smoke evidence

This commit is contained in:
lix-2026
2026-05-21 17:15:28 +08:00
parent 1e8f5c5816
commit cc91a56603
16 changed files with 1093 additions and 45 deletions
@@ -2127,6 +2127,22 @@ pub fn save_local_markdown_page(
document_id: &str,
expected_conflict_detection_key: Option<&str>,
content: &Value,
) -> Result<Value, WebError> {
save_local_markdown_page_inner(
root_uri,
document_id,
expected_conflict_detection_key,
content,
None,
)
}
fn save_local_markdown_page_inner(
root_uri: &str,
document_id: &str,
expected_conflict_detection_key: Option<&str>,
content: &Value,
buffer_store: Option<&crate::document_buffer_store::BufferStore>,
) -> Result<Value, WebError> {
let root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| {
@@ -2143,6 +2159,13 @@ pub fn save_local_markdown_page(
"找不到要保存的本地 Markdown 页面",
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
let relative_path_for_buffer = markdown_file
.path
.strip_prefix(&canonical_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
WebError::bad_request_code(
"local_markdown_read_failed",
@@ -2159,11 +2182,21 @@ pub fn save_local_markdown_page(
.filter(|value| !value.is_empty())
{
if expected_key != current_conflict_key {
let buffer_state = buffer_store.and_then(|store| {
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
&workspace_id,
root_uri,
&relative_path_for_buffer,
document_id,
);
store.get_by_path(&ws_path)
});
return Err(local_markdown_conflict_error(
root_uri,
document_id,
expected_key,
&current_conflict_key,
buffer_state.as_ref(),
));
}
}
@@ -2183,13 +2216,6 @@ pub fn save_local_markdown_page(
),
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
let relative_path_for_buffer = markdown_file
.path
.strip_prefix(&canonical_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
let next_conflict_key =
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
@@ -2216,19 +2242,18 @@ fn local_markdown_conflict_error(
document_id: &str,
editor_base_version: &str,
current_disk_version: &str,
buffer_state: Option<&core_protocol::DocumentBuffer>,
) -> WebError {
WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
)
.with_details(json!({
let mut details = json!({
"conflict": {
"code": "local_markdown_external_change",
"documentId": document_id,
"rootUri": root_uri,
"currentDiskVersion": current_disk_version,
"editorBaseVersion": editor_base_version,
"externalActor": Value::Null,
"dirtyState": Value::Null,
"bufferFileVersion": Value::Null,
"suggestedActions": [
"accept_disk",
"keep_editor",
@@ -2236,7 +2261,20 @@ fn local_markdown_conflict_error(
"merge"
]
}
}))
});
if let Some(buf) = buffer_state {
if let Some(conflict) = details.get_mut("conflict").and_then(Value::as_object_mut) {
conflict.insert("externalActor".into(), json!(&buf.external_actor));
conflict.insert("dirtyState".into(), json!(format!("{:?}", buf.dirty_state)));
conflict.insert("bufferFileVersion".into(), json!(&buf.file_version));
}
}
WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
)
.with_details(details)
}
pub fn write_local_markdown_page_body(
@@ -2256,12 +2294,22 @@ pub fn write_local_markdown_page_body(
));
}
let mut result = save_local_markdown_page(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
)?;
let mut result = if buffer_store.is_some() {
save_local_markdown_page_inner(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
buffer_store,
)?
} else {
save_local_markdown_page(
&request.root_uri,
&request.document_id,
request.expected_file_version.as_deref(),
&request.content,
)?
};
// 保存成功后更新 BufferStore
if let Some(store) = buffer_store {
@@ -7843,6 +7891,55 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_fails_with_error_code_on_readonly_file() {
let root = temp_root("mnote-local-markdown-save-readonly-error");
std::fs::write(
root.join("README.md"),
"---\ntitle: Error Test\n---\n# Original\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 把文件设为只读,使 fs::write 失败。
let mut perms = std::fs::metadata(root.join("README.md"))
.expect("metadata")
.permissions();
perms.set_readonly(true);
std::fs::set_permissions(root.join("README.md"), perms).expect("set readonly");
let error = save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"paragraph","content":[{"type":"text","text":"新内容","styles":{}}]}
]),
)
.expect_err("save 应在只读文件上失败");
assert_eq!(
error.code(),
"local_markdown_write_failed",
"错误码应为 local_markdown_write_failed,实际: {}",
error.code()
);
assert!(
error.message().contains("无法保存本地 Markdown 文件"),
"错误信息应包含中文解释和文件路径,实际: {}",
error.message()
);
// 验证磁盘上的原始内容未被清除。
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(
saved.contains("# Original"),
"原文应保留,实际内容: {}",
saved
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_table_inline_marks() {
let root = temp_root("mnote-local-markdown-save-table-inline-marks");
@@ -7930,6 +8027,50 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_table_handles_colspan_degradation() {
// Tiptap table cell 可能包含 colspan/rowspan 等 GFM pipe table
// 无法表达的结构。降级策略:提取文本内容,忽略合并单元格属性,
// 输出标准 pipe table,不丢文字。
let root = temp_root("mnote-local-markdown-save-table-colspan");
std::fs::write(root.join("README.md"), "---\ntitle: Colspan\n---\n# Old\n")
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 第二行第二个单元格有 colspan=2GFM pipe table 不支持,应降级为普通单元格。
save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
{"type":"tableRow","content":[
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
]},
{"type":"tableRow","content":[
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"x","styles":{}}]}]},
{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"y+z","styles":{}}]}]}
]}
]}}}
]),
)
.expect("save");
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
// 降级后 colspan=2 的单元格变成普通单列,GFM 表头行 3 列但数据行 2 列 → 右列留空。
// 重点:文字不丢。
assert!(
saved.contains("y+z"),
"colspan 单元格文字不应丢失: {}",
saved
);
// 不会 panic,表格行数对齐由 editor_block_table_to_markdown 的 padding 逻辑保证。
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_writes_image_blocks_as_markdown_images() {
let root = temp_root("mnote-local-markdown-save-image-block");