收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
@@ -3,6 +3,7 @@ use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
};
use crate::routes::local_ocr;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
@@ -55,6 +56,7 @@ pub(crate) fn query_local_search_index(
limit: u32,
title_only: bool,
exact: bool,
include_ocr: bool,
) -> Result<Value, WebError> {
let index = load_or_rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let normalized_query = normalize_search_text(query);
@@ -90,6 +92,39 @@ pub(crate) fn query_local_search_index(
}
}
}
if include_ocr && results.len() < limit.max(1) as usize {
for entry in local_ocr::ocr_index_entries(root_path)? {
if let Some(page_id) = page_id {
if entry.owner_document_id != page_id {
continue;
}
}
if entry.status != "done" {
continue;
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let markdown = match fs::read_to_string(&ocr_path) {
Ok(markdown) => markdown,
Err(_) => continue,
};
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
continue;
}
let body = local_ocr::strip_ocr_frontmatter(&markdown);
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_ocr_projection(
&entry,
body,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -100,7 +135,8 @@ pub(crate) fn query_local_search_index(
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
"resourceCount": index.resources.len(),
"includeOcr": include_ocr
},
"recentChanges": recent_changes,
"results": results
@@ -404,6 +440,14 @@ fn collect_markdown_documents(
continue;
}
if is_markdown_path(&path) {
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
continue;
}
documents.push(index_markdown_file(root_path, &path)?);
} else if resource_type_from_path(&path).is_some() {
resources.push(index_resource_file(root_path, &path)?);
@@ -565,6 +609,34 @@ fn local_search_resource_matches(
}
}
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -608,6 +680,42 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -764,6 +872,27 @@ fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
document.raw_text.chars().take(180).collect()
}
fn ocr_search_snippet(body: &str, query: &str) -> String {
let normalized_body = body.replace('\n', " ");
let normalized_query = query.trim();
if normalized_query.is_empty() {
return normalized_body.chars().take(160).collect();
}
let lower = normalized_body.to_ascii_lowercase();
let lower_query = normalized_query.to_ascii_lowercase();
if let Some(byte_index) = lower.find(&lower_query) {
let start = normalized_body[..byte_index]
.char_indices()
.rev()
.nth(40)
.map(|(idx, _)| idx)
.unwrap_or(0);
normalized_body[start..].chars().take(160).collect()
} else {
normalized_body.chars().take(160).collect()
}
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
@@ -845,6 +974,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("projection");
let results = projection["results"].as_array().expect("results");
@@ -898,6 +1028,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("child search");
let child_result = child_search["results"]
@@ -929,6 +1060,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("mindmap projection");
assert!(mindmap_projection["results"]
@@ -953,6 +1085,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("office projection");
assert!(office_projection["results"]
@@ -1017,6 +1150,104 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
let root = temp_root("mnote-local-search-ocr");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-ocr";
fs::write(root.join("docs").join("Page.md"), "# Page\n正文\n").expect("page");
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
fs::write(
root.join("docs").join("Page.assets").join("photo.png"),
b"png",
)
.expect("photo");
fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
fs::write(
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
)
.expect("ocr markdown");
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
fs::write(
root.join(".mnote").join("ocr-index.json"),
serde_json::to_string_pretty(&json!({
"version": 1,
"entries": {
"docs/Page.assets/photo.png": {
"jobId": "ocr_test",
"ownerDocumentId": "local-md:docs~2FPage.md",
"ownerDocumentPath": "docs/Page.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"provider": "mock",
"modelVersion": "vlm",
"status": "done",
"sourceSize": 3,
"sourceMtimeMs": 1,
"createdAtMs": 1,
"updatedAtMs": 1,
"plainTextPreview": "OCR-only-token 识别正文"
}
}
}))
.expect("serialize index"),
)
.expect("ocr index");
let without_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
false,
)
.expect("without ocr");
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
let with_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-only-token",
None,
10,
false,
false,
true,
)
.expect("with ocr");
let result = with_ocr["results"]
.as_array()
.and_then(|items| items.first())
.expect("ocr result");
assert_eq!(
result["documentId"].as_str(),
Some("local-md:docs~2FPage.md")
);
assert_eq!(result["hasOcr"].as_bool(), Some(true));
assert_eq!(
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
assert_eq!(
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
let index = read_local_search_index(&root)
.expect("read search index")
.expect("search index");
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_query_reads_existing_index_without_rebuilding() {
let root = temp_root("mnote-local-search-query-cache");
@@ -1038,6 +1269,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("first query");
assert_eq!(
@@ -1060,6 +1292,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query existing index");
assert_eq!(
@@ -1078,6 +1311,7 @@ mod tests {
10,
false,
false,
false,
)
.expect("query refreshed index");
assert_eq!(