feat: advance local-first conflict and search indexing

- 补齐本地 markdown 冲突处理与合并写回路径\n- 增加本地搜索索引路由、刷新与 browser smoke\n- 同步更新 current-priority checklist 的阶段进度
This commit is contained in:
lix-2026
2026-05-19 09:38:57 +08:00
parent 8ed594f1c2
commit 1b5d6a2a2d
12 changed files with 1622 additions and 49 deletions
@@ -7,6 +7,7 @@ use crate::page_aggregate::{
use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_search_index;
use crate::routes::snapshot_support::ProjectionSnapshot;
use axum::extract::{Extension, Multipart, Path as AxumPath, Query};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
@@ -1380,10 +1381,11 @@ pub fn save_local_markdown_page(
.filter(|value| !value.is_empty())
{
if expected_key != current_conflict_key {
return Err(WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
return Err(local_markdown_conflict_error(
root_uri,
document_id,
expected_key,
&current_conflict_key,
));
}
}
@@ -1403,6 +1405,8 @@ pub fn save_local_markdown_page(
),
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
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)?;
Ok(json!({
@@ -1417,6 +1421,38 @@ pub fn save_local_markdown_page(
}))
}
fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) {
let _ = local_search_index::refresh_local_search_index(root, root_uri, workspace_id);
}
fn local_markdown_conflict_error(
root_uri: &str,
document_id: &str,
editor_base_version: &str,
current_disk_version: &str,
) -> WebError {
WebError::new(
StatusCode::CONFLICT,
"local_markdown_external_change",
"本地 Markdown 文件已被外部修改,请刷新后再保存",
)
.with_details(json!({
"conflict": {
"code": "local_markdown_external_change",
"documentId": document_id,
"rootUri": root_uri,
"currentDiskVersion": current_disk_version,
"editorBaseVersion": editor_base_version,
"suggestedActions": [
"accept_disk",
"keep_editor",
"open_diff",
"merge"
]
}
}))
}
pub fn write_local_markdown_page_body(
request: &core_protocol::PageBodyWriteRequest,
) -> Result<Value, WebError> {
@@ -1765,6 +1801,8 @@ pub fn update_local_markdown_title(
),
)
})?;
let workspace_id = local_workspace_id(&canonical_root);
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
Ok(json!({
"ok": true,
"documentId": document_id,
@@ -1814,7 +1852,7 @@ pub fn execute_local_tree_command(
format!("无法访问本地文件夹: {error}"),
)
})?;
match action {
let result = match action {
"create" => {
create_local_markdown_page(&canonical_root, parent_id, title.unwrap_or("新页面"))
}
@@ -1838,7 +1876,10 @@ pub fn execute_local_tree_command(
"local_tree_command_unsupported",
format!("local_folder 暂不支持 tree action: {other}"),
)),
}
}?;
let workspace_id = local_workspace_id(&canonical_root);
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
Ok(result)
}
fn create_local_markdown_page(
@@ -4412,12 +4453,12 @@ mod tests {
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
create_local_access_grant, ensure_local_path_read_access,
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
get_local_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor,
load_local_folder_page_tree_snapshot, local_folder_watch_revision, open_local_file,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
validate_local_access_root, write_local_markdown_asset, write_local_markdown_page_body,
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalUploadFile,
execute_local_tree_command, get_local_access_policy, initialize_local_page_id,
initialize_local_workspace_for_actor, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, open_local_file, resolve_local_markdown_page_aggregate,
save_local_markdown_page, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalUploadFile,
};
use crate::context::RequestContext;
use axum::extract::{Extension, Path as AxumPath, Query};
@@ -5353,6 +5394,88 @@ fn main() {}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn local_tree_command_refreshes_search_index_after_rename() {
let root = temp_root("mnote-local-tree-refresh-search-index");
init_workspace(&root);
std::fs::write(
root.join("search-target.md"),
"# Search Target\nrename-token\n",
)
.expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_search_index::refresh_local_search_index(
&root,
&root_uri,
"local-ws-search-index-test",
)
.expect("initial index");
let before =
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
.expect("before index");
assert!(before.contains("search-target.md"));
execute_local_tree_command(
&root_uri,
"rename",
"local-md:search-target.md",
None,
Some("Renamed Target"),
)
.expect("rename");
let after =
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
.expect("after index");
assert!(after.contains("Renamed Target.md"), "{after}");
assert!(!after.contains("search-target.md"), "{after}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_refreshes_search_index_after_write() {
let root = temp_root("mnote-local-save-refresh-search-index");
init_workspace(&root);
std::fs::write(root.join("README.md"), "# Before\nold-token\n").expect("write md");
let root_uri = format!("file://{}", root.display());
crate::routes::local_search_index::refresh_local_search_index(
&root,
&root_uri,
"local-ws-search-index-test",
)
.expect("initial index");
save_local_markdown_page(
&root_uri,
"local-md:README.md",
None,
&serde_json::json!([
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "After" }]
},
{
"id": "p_1",
"type": "paragraph",
"content": [{ "type": "text", "text": "new-token" }]
}
]),
)
.expect("save markdown");
let index =
std::fs::read_to_string(root.join(".mnote").join("index").join("search-index.json"))
.expect("index");
assert!(index.contains("new-token"), "{index}");
assert!(!index.contains("old-token"), "{index}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_asset_upload_copies_next_to_markdown_with_relative_path() {
let root = temp_root("mnote-local-markdown-asset-upload");