Files
mnote/rust/crates/mnote-web/src/routes/local_search_index.rs
T

1375 lines
45 KiB
Rust

use crate::error::WebError;
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;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchIndex {
version: u32,
built_at: u128,
root_uri: String,
workspace_id: String,
documents: Vec<LocalSearchDocument>,
#[serde(default)]
resources: Vec<LocalSearchResource>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchDocument {
document_id: String,
title: String,
path: String,
raw_text: String,
tags: Vec<String>,
backlinks: Vec<String>,
resource_refs: Vec<String>,
updated_at: u128,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LocalSearchResource {
resource_id: String,
resource_type: String,
title: String,
path: String,
updated_at: u128,
}
pub(crate) fn query_local_search_index(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
query: &str,
page_id: Option<&str>,
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);
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
let mut results = Vec::new();
for document in index.documents.iter() {
if let Some(page_id) = page_id {
if document.document_id != page_id {
continue;
}
}
if !local_search_document_matches(document, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_document_projection(
document,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
if page_id.is_none() && results.len() < limit.max(1) as usize {
for resource in index.resources.iter() {
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_resource_projection(resource, root_uri));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
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",
"sourceKind": "local_folder",
"index": {
"version": index.version,
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len(),
"includeOcr": include_ocr
},
"recentChanges": recent_changes,
"results": results
}))
}
pub(crate) fn refresh_local_search_index(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
) -> Result<Value, WebError> {
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
}))
}
pub(crate) fn refresh_local_search_index_for_path(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
relative_path: &str,
) -> Result<Value, WebError> {
let relative_path = normalize_index_relative_path(relative_path)?;
let mut index = match read_local_search_index(root_path) {
Ok(Some(index))
if index.version == LOCAL_SEARCH_INDEX_VERSION
&& index.root_uri == root_uri
&& index.workspace_id == workspace_id =>
{
index
}
Ok(_) | Err(_) => {
return refresh_local_search_index(root_path, root_uri, workspace_id);
}
};
index
.documents
.retain(|document| document.path != relative_path);
index
.resources
.retain(|resource| resource.path != relative_path);
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
index.built_at = now_ms();
write_local_search_index(root_path, &index)?;
return Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
}));
}
let absolute_path = root_path.join(&relative_path);
if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
index
.documents
.push(index_markdown_file(root_path, &absolute_path)?);
} else if absolute_path.exists()
&& absolute_path.is_file()
&& resource_type_from_path(&absolute_path).is_some()
{
index
.resources
.push(index_resource_file(root_path, &absolute_path)?);
}
index
.documents
.sort_by(|left, right| left.path.cmp(&right.path));
index
.resources
.sort_by(|left, right| left.path.cmp(&right.path));
index.built_at = now_ms();
write_local_search_index(root_path, &index)?;
Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
"workspaceId": index.workspace_id,
"builtAt": index.built_at,
"documentCount": index.documents.len(),
"resourceCount": index.resources.len()
}))
}
pub(crate) fn query_local_backlinks(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
document_id: &str,
) -> Result<Value, WebError> {
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let Some(target) = index
.documents
.iter()
.find(|document| document.document_id == document_id)
else {
return Ok(json!({
"documentId": document_id,
"rootUri": root_uri,
"workspaceId": workspace_id,
"backlinks": []
}));
};
let target_keys = normalized_document_targets(target);
let backlinks = index
.documents
.iter()
.filter(|document| document.document_id != target.document_id)
.filter_map(|document| {
let matched_targets = document
.backlinks
.iter()
.filter(|link| target_keys.contains(&normalize_search_text(link)))
.cloned()
.collect::<Vec<_>>();
if matched_targets.is_empty() {
return None;
}
Some(json!({
"documentId": document.document_id,
"title": document.title,
"path": document.path,
"resourceType": "markdown",
"matchedTargets": matched_targets,
"snippet": search_snippet(document, &normalize_search_text(&target.title))
}))
})
.collect::<Vec<_>>();
Ok(json!({
"documentId": target.document_id,
"title": target.title,
"path": target.path,
"rootUri": root_uri,
"workspaceId": workspace_id,
"backlinks": backlinks
}))
}
pub(crate) fn query_local_tags(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
) -> Result<Value, WebError> {
let index = rebuild_local_search_index(root_path, root_uri, workspace_id)?;
let mut tags = std::collections::BTreeMap::<String, Vec<&LocalSearchDocument>>::new();
for document in &index.documents {
for tag in &document.tags {
tags.entry(tag.clone()).or_default().push(document);
}
}
Ok(json!({
"rootUri": root_uri,
"workspaceId": workspace_id,
"tags": tags
.into_iter()
.map(|(tag, documents)| {
json!({
"tag": tag,
"count": documents.len(),
"documents": documents
.into_iter()
.map(|document| {
json!({
"documentId": document.document_id,
"title": document.title,
"path": document.path,
"resourceType": "markdown"
})
})
.collect::<Vec<_>>()
})
})
.collect::<Vec<_>>()
}))
}
fn rebuild_local_search_index(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
) -> Result<LocalSearchIndex, WebError> {
let mut documents = Vec::new();
let mut resources = Vec::new();
collect_markdown_documents(root_path, root_path, &mut documents, &mut resources)?;
documents.sort_by(|left, right| left.path.cmp(&right.path));
resources.sort_by(|left, right| left.path.cmp(&right.path));
let index = LocalSearchIndex {
version: LOCAL_SEARCH_INDEX_VERSION,
built_at: now_ms(),
root_uri: root_uri.to_string(),
workspace_id: workspace_id.to_string(),
documents,
resources,
};
write_local_search_index(root_path, &index)?;
Ok(index)
}
fn load_or_rebuild_local_search_index(
root_path: &Path,
root_uri: &str,
workspace_id: &str,
) -> Result<LocalSearchIndex, WebError> {
match read_local_search_index(root_path) {
Ok(Some(index))
if index.version == LOCAL_SEARCH_INDEX_VERSION
&& index.root_uri == root_uri
&& index.workspace_id == workspace_id =>
{
Ok(index)
}
Ok(_) | Err(_) => rebuild_local_search_index(root_path, root_uri, workspace_id),
}
}
fn read_local_search_index(root_path: &Path) -> Result<Option<LocalSearchIndex>, WebError> {
let index_path = root_path
.join(".mnote")
.join("index")
.join("search-index.json");
if !index_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&index_path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_read_failed",
format!("无法读取本地搜索索引 {}: {error}", index_path.display()),
)
})?;
serde_json::from_str::<LocalSearchIndex>(&content)
.map(Some)
.map_err(|error| {
WebError::bad_request_code(
"local_search_index_invalid",
format!("本地搜索索引格式非法 {}: {error}", index_path.display()),
)
})
}
fn normalize_index_relative_path(relative_path: &str) -> Result<String, WebError> {
let normalized = relative_path.trim().replace('\\', "/");
if normalized.is_empty() {
return Err(WebError::bad_request_code(
"local_search_index_path_required",
"本地搜索索引更新缺少相对路径",
));
}
let path = PathBuf::from(&normalized);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(WebError::bad_request_code(
"local_search_index_path_escape",
"本地搜索索引相对路径不能越过 root",
));
}
Ok(normalized)
}
fn collect_markdown_documents(
root_path: &Path,
current: &Path,
documents: &mut Vec<LocalSearchDocument>,
resources: &mut Vec<LocalSearchResource>,
) -> Result<(), WebError> {
let entries = match fs::read_dir(current) {
Ok(entries) => entries,
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_read_dir_failed",
format!("无法读取本地搜索索引目录 {}: {error}", current.display()),
));
}
};
for entry in entries {
let entry = entry.map_err(|error| {
WebError::bad_request_code(
"local_search_index_read_entry_failed",
format!("无法读取本地搜索索引条目: {error}"),
)
})?;
let path = entry.path();
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
if file_name == ".mnote" {
continue;
}
let file_type = entry.file_type().map_err(|error| {
WebError::bad_request_code(
"local_search_index_stat_failed",
format!("无法读取本地搜索索引文件状态 {}: {error}", path.display()),
)
})?;
if file_type.is_dir() {
collect_markdown_documents(root_path, &path, documents, resources)?;
continue;
}
if !file_type.is_file() {
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)?);
}
}
Ok(())
}
fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocument, WebError> {
let markdown = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_read_markdown_failed",
format!("无法读取本地 Markdown 索引文件 {}: {error}", path.display()),
)
})?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
let parsed = parse_markdown_page(&markdown, file_name);
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let document_id = format!("local-md:{}", encode_local_id_segment(&relative_path));
let metadata = fs::metadata(path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_stat_failed",
format!(
"无法读取本地 Markdown 索引文件状态 {}: {error}",
path.display()
),
)
})?;
Ok(LocalSearchDocument {
document_id,
title: parsed.title,
path: relative_path,
raw_text: parsed.body.clone(),
tags: extract_tags(&markdown),
backlinks: extract_backlinks(&parsed.body),
resource_refs: extract_resource_refs(&parsed.body),
updated_at: metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis())
.unwrap_or_default(),
})
}
fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResource, WebError> {
let resource_type = resource_type_from_path(path).ok_or_else(|| {
WebError::bad_request_code(
"local_search_index_resource_type_unsupported",
format!("不支持索引该资源文件 {}", path.display()),
)
})?;
let relative_path = path
.strip_prefix(root_path)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
let title = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("resource")
.trim()
.trim_end_matches(".mindmap")
.to_string();
let metadata = fs::metadata(path).map_err(|error| {
WebError::bad_request_code(
"local_search_index_stat_failed",
format!("无法读取本地资源索引文件状态 {}: {error}", path.display()),
)
})?;
Ok(LocalSearchResource {
resource_id: format!("local-resource:{}", relative_path.replace('/', "~2F")),
resource_type: resource_type.to_string(),
title,
path: relative_path,
updated_at: metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis())
.unwrap_or_default(),
})
}
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
let index_dir = root_path.join(".mnote").join("index");
fs::create_dir_all(&index_dir).map_err(|error| {
WebError::bad_request_code(
"local_search_index_create_failed",
format!("无法创建本地搜索索引目录 {}: {error}", index_dir.display()),
)
})?;
let index_path = index_dir.join("search-index.json");
let payload = serde_json::to_string_pretty(index)
.map_err(|error| WebError::internal(format!("本地搜索索引序列化失败: {error}")))?;
fs::write(&index_path, format!("{payload}\n")).map_err(|error| {
WebError::bad_request_code(
"local_search_index_write_failed",
format!("无法写入本地搜索索引 {}: {error}", index_path.display()),
)
})
}
fn local_search_document_matches(
document: &LocalSearchDocument,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&document.title)
} else {
normalize_search_text(&format!(
"{}\n{}\n{}\n{}",
document.title,
document.raw_text,
document.tags.join(" "),
document.resource_refs.join(" ")
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
fn local_search_resource_matches(
resource: &LocalSearchResource,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&resource.title)
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
resource.title, resource.path, resource.resource_type
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
}
}
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,
query: &str,
) -> Value {
json!({
"id": document.document_id,
"documentId": document.document_id,
"title": document.title,
"path": document.path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"snippet": search_snippet(document, query),
"tags": document.tags,
"backlinks": document.backlinks,
"resourceRefs": document.resource_refs,
"updatedAt": document.updated_at,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
document.document_id,
encode_query_component(root_uri),
)
})
}
fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &str) -> Value {
json!({
"id": resource.resource_id,
"documentId": resource.resource_id,
"title": resource.title,
"path": resource.path,
"resourceType": resource.resource_type,
"sourceKind": "local_folder",
"rootUri": root_uri,
"updatedAt": resource.updated_at,
"publicPath": format!(
"/?treeView=filetree&sourceKind=local_folder&rootUri={}",
encode_query_component(root_uri),
)
})
}
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() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(*byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn local_recent_changes_projection(documents: &[LocalSearchDocument], root_uri: &str) -> Value {
let mut sorted = documents.iter().collect::<Vec<_>>();
sorted.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
Value::Array(
sorted
.into_iter()
.take(20)
.map(|document| {
json!({
"id": document.document_id,
"documentId": document.document_id,
"title": document.title,
"path": document.path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"updatedAt": document.updated_at
})
})
.collect(),
)
}
fn normalized_document_targets(
document: &LocalSearchDocument,
) -> std::collections::BTreeSet<String> {
let mut values = std::collections::BTreeSet::new();
values.insert(normalize_search_text(&document.document_id));
values.insert(normalize_search_text(&document.path));
values.insert(normalize_search_text(&document.title));
if let Some(encoded) = document.document_id.strip_prefix("local-md:") {
values.insert(normalize_search_text(&encoded.replace("~2F", "/")));
}
values
}
fn extract_tags(markdown: &str) -> Vec<String> {
let Some(frontmatter) = split_frontmatter(markdown).0 else {
return Vec::new();
};
for line in frontmatter.lines() {
let trimmed = line.trim();
let Some(value) = trimmed
.strip_prefix("tags:")
.or_else(|| trimmed.strip_prefix("tag:"))
else {
continue;
};
return value
.trim()
.trim_matches(['[', ']'])
.split([',', ' '])
.map(|item| item.trim().trim_matches(['"', '\'']))
.filter(|item| !item.is_empty())
.map(ToOwned::to_owned)
.collect();
}
Vec::new()
}
fn extract_backlinks(markdown: &str) -> Vec<String> {
let mut values = extract_markdown_links(markdown)
.into_iter()
.filter(|target| is_markdown_reference(target))
.collect::<Vec<_>>();
let mut rest = markdown;
while let Some(start) = rest.find("[[") {
let after_start = &rest[start + 2..];
let Some(end) = after_start.find("]]") else {
break;
};
let link = after_start[..end].trim();
if !link.is_empty() {
values.push(link.to_string());
}
rest = &after_start[end + 2..];
}
values.sort();
values.dedup();
values
}
fn extract_resource_refs(markdown: &str) -> Vec<String> {
let mut values = markdown
.lines()
.filter_map(|line| parse_markdown_attachment_link(line.trim()))
.map(|(_, target)| target)
.chain(
extract_markdown_links(markdown)
.into_iter()
.filter(|target| {
!is_markdown_reference(target) && is_local_resource_reference(target)
}),
)
.collect::<Vec<_>>();
values.sort();
values.dedup();
values
}
fn extract_markdown_links(markdown: &str) -> Vec<String> {
let mut links = Vec::new();
let mut rest = markdown;
while let Some(label_end) = rest.find("](") {
let after = &rest[label_end + 2..];
let Some(target_end) = after.find(')') else {
break;
};
let target = after[..target_end].trim();
if !target.is_empty() {
links.push(target.to_string());
}
rest = &after[target_end + 1..];
}
links
}
fn is_markdown_reference(target: &str) -> bool {
let lower = target.to_lowercase();
lower.ends_with(".md") || lower.ends_with(".markdown") || lower.starts_with("local-md:")
}
fn is_local_resource_reference(target: &str) -> bool {
let lower = target.to_lowercase();
!lower.starts_with("http://")
&& !lower.starts_with("https://")
&& !lower.starts_with("mailto:")
&& !lower.starts_with('#')
&& Path::new(target).extension().is_some()
}
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
for line in document.raw_text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if normalize_search_text(trimmed).contains(query) {
return trimmed.chars().take(180).collect();
}
}
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())
.map(|value| value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown"))
.unwrap_or(false)
}
fn resource_type_from_path(path: &Path) -> Option<&'static str> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
if file_name.ends_with(".mindmap.json") {
return Some("mindmap");
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
match extension.as_str() {
"doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" => Some("office"),
_ => None,
}
}
fn normalize_search_text(value: &str) -> String {
value.trim().to_lowercase()
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("docs")).expect("create root");
root
}
#[test]
fn local_search_index_extracts_backlinks_resource_refs_and_tags() {
let root = temp_root("mnote-local-search-index");
fs::write(
root.join("README.md"),
"---\ntitle: Home\ntags: [alpha, beta]\n---\n# Home\nSee [[Daily]] and [Child](docs/child.md).\n[Spec](assets/spec.pdf)\n[Map](maps/idea.mindmap.json)\n[Sheet](office/report.xlsx)\n![Diagram](assets/diagram.png)\n",
)
.expect("write readme");
fs::write(
root.join("docs").join("child.md"),
"---\nmnote_id: child-page\ntitle: Child\n---\nChild body with alpha and office.xlsx\n",
)
.expect("write child");
fs::create_dir_all(root.join("maps")).expect("create maps");
fs::create_dir_all(root.join("office")).expect("create office");
fs::write(
root.join("maps").join("idea.mindmap.json"),
r#"{"title":"Idea Map","nodes":[]}"#,
)
.expect("write mindmap");
fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office");
let projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"alpha",
None,
10,
false,
false,
false,
)
.expect("projection");
let results = projection["results"].as_array().expect("results");
let home = results
.iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result");
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
assert!(home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
)));
// 含 mnote_id 的 Markdown 仍返回路径型 documentId,不应出现 local-mdid:
let child_search = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"office.xlsx",
None,
10,
false,
false,
false,
)
.expect("child search");
let child_result = child_search["results"]
.as_array()
.unwrap()
.iter()
.find(|item| item["path"].as_str() == Some("docs/child.md"))
.expect("child in search results");
assert_eq!(
child_result["documentId"].as_str(),
Some("local-md:docs~2Fchild.md")
);
assert_ne!(
child_result["documentId"].as_str(),
Some("local-mdid:child-page")
);
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let mindmap_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"idea.mindmap",
None,
10,
false,
false,
false,
)
.expect("mindmap projection");
assert!(mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"]
.as_str()
.is_some_and(|path| path
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?"))));
let office_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
"local-ws-test",
"report.xlsx",
None,
10,
false,
false,
false,
)
.expect("office projection");
assert!(office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_search_index_incrementally_updates_single_markdown_path() {
let root = temp_root("mnote-local-search-index-incremental");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-incremental";
fs::write(root.join("README.md"), "# Home\nRoot body.\n").expect("write home");
fs::write(
root.join("docs").join("child.md"),
"---\ntitle: Child\n---\n# Child\nOriginal body.\n",
)
.expect("write child");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
fs::write(
root.join("docs").join("child.md"),
"---\ntitle: Child Updated\n---\n# Child Updated\nChangedToken body.\n",
)
.expect("update child");
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental update");
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let child = index
.documents
.iter()
.find(|document| document.path == "docs/child.md")
.expect("child document");
// 本地 Markdown 的树标题统一来自文件名;frontmatter/H1 只进入正文和索引文本。
assert_eq!(child.title, "child");
assert!(child.raw_text.contains("ChangedToken"));
fs::remove_file(root.join("docs").join("child.md")).expect("remove child");
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental remove");
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
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::write(
root.join("docs").join("Page.ocr").join("photo.png-704905.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: 2\nstatus: done\ncreated_at: 2\nupdated_at: 2\n---\n\nOCR-hash-token 识别正文\n",
)
.expect("hashed 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 without_hashed_ocr = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OCR-hash-token",
None,
10,
false,
false,
false,
)
.expect("without hashed ocr");
assert_eq!(
without_hashed_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"));
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
refresh_local_search_index_for_path(
&root,
&root_uri,
workspace_id,
"docs/Page.ocr/photo.png-704905.ocr.md",
)
.expect("refresh hashed ocr sidecar");
let refreshed_index = read_local_search_index(&root)
.expect("read refreshed search index")
.expect("refreshed search index");
assert!(!refreshed_index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.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");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-query-cache";
let child_path = root.join("docs").join("child.md");
fs::write(
&child_path,
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
)
.expect("write child");
let first_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"OriginalToken",
None,
10,
false,
false,
false,
)
.expect("first query");
assert_eq!(
first_projection["results"].as_array().map(Vec::len),
Some(1)
);
fs::write(
&child_path,
"---\ntitle: Child\n---\n# Child\nUnindexedToken body.\n",
)
.expect("update child without refresh");
let stale_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"UnindexedToken",
None,
10,
false,
false,
false,
)
.expect("query existing index");
assert_eq!(
stale_projection["results"].as_array().map(Vec::len),
Some(0)
);
refresh_local_search_index_for_path(&root, &root_uri, workspace_id, "docs/child.md")
.expect("incremental refresh");
let refreshed_projection = query_local_search_index(
&root,
&root_uri,
workspace_id,
"UnindexedToken",
None,
10,
false,
false,
false,
)
.expect("query refreshed index");
assert_eq!(
refreshed_projection["results"].as_array().map(Vec::len),
Some(1)
);
let _ = fs::remove_dir_all(&root);
}
}