推进本地索引与 AI changed-files 审计
- 为本地工作区补充 search/backlinks/tags/resource 引用索引与 watcher 单文件刷新 - 在页面设置中增加索引页签,并展示本地反链、标签和 AI changed-files 审计 - 补充本地搜索与本地 AI changed-files 浏览器 smoke,并回填当前优先级 checklist
This commit is contained in:
@@ -5,7 +5,7 @@ use crate::routes::local_markdown_parser::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
|
||||
@@ -18,6 +18,8 @@ struct LocalSearchIndex {
|
||||
root_uri: String,
|
||||
workspace_id: String,
|
||||
documents: Vec<LocalSearchDocument>,
|
||||
#[serde(default)]
|
||||
resources: Vec<LocalSearchResource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -33,6 +35,16 @@ struct LocalSearchDocument {
|
||||
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,
|
||||
@@ -66,6 +78,17 @@ pub(crate) fn query_local_search_index(
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"enqueueAssetIds": [],
|
||||
"projectionOwner": "rust-kernel",
|
||||
@@ -75,7 +98,8 @@ pub(crate) fn query_local_search_index(
|
||||
"rootUri": index.root_uri,
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len()
|
||||
"documentCount": index.documents.len(),
|
||||
"resourceCount": index.resources.len()
|
||||
},
|
||||
"recentChanges": recent_changes,
|
||||
"results": results
|
||||
@@ -93,7 +117,65 @@ pub(crate) fn refresh_local_search_index(
|
||||
"rootUri": index.root_uri,
|
||||
"workspaceId": index.workspace_id,
|
||||
"builtAt": index.built_at,
|
||||
"documentCount": index.documents.len()
|
||||
"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);
|
||||
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()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -195,23 +277,76 @@ fn rebuild_local_search_index(
|
||||
workspace_id: &str,
|
||||
) -> Result<LocalSearchIndex, WebError> {
|
||||
let mut documents = Vec::new();
|
||||
collect_markdown_documents(root_path, root_path, &mut documents)?;
|
||||
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 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,
|
||||
@@ -244,13 +379,17 @@ fn collect_markdown_documents(
|
||||
)
|
||||
})?;
|
||||
if file_type.is_dir() {
|
||||
collect_markdown_documents(root_path, &path, documents)?;
|
||||
collect_markdown_documents(root_path, &path, documents, resources)?;
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() || !is_markdown_path(&path) {
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
documents.push(index_markdown_file(root_path, &path)?);
|
||||
if is_markdown_path(&path) {
|
||||
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(())
|
||||
}
|
||||
@@ -303,6 +442,45 @@ fn index_markdown_file(root_path: &Path, path: &Path) -> Result<LocalSearchDocum
|
||||
})
|
||||
}
|
||||
|
||||
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| {
|
||||
@@ -349,6 +527,30 @@ fn local_search_document_matches(
|
||||
}
|
||||
}
|
||||
|
||||
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_document_projection(
|
||||
document: &LocalSearchDocument,
|
||||
root_uri: &str,
|
||||
@@ -371,6 +573,20 @@ fn local_search_document_projection(
|
||||
})
|
||||
}
|
||||
|
||||
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!("/tree?sourceKind=local_folder&rootUri={}", root_uri)
|
||||
})
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -522,6 +738,26 @@ fn is_markdown_path(path: &Path) -> bool {
|
||||
.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()
|
||||
}
|
||||
@@ -558,6 +794,14 @@ mod tests {
|
||||
"---\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,
|
||||
@@ -610,6 +854,92 @@ mod tests {
|
||||
.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,
|
||||
)
|
||||
.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")));
|
||||
let office_projection = query_local_search_index(
|
||||
&root,
|
||||
&format!("file://{}", root.display()),
|
||||
"local-ws-test",
|
||||
"report.xlsx",
|
||||
None,
|
||||
10,
|
||||
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");
|
||||
assert_eq!(child.title, "Child Updated");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title,
|
||||
update_local_page_options, write_local_markdown_page_body,
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, local_workspace_id_from_root_uri,
|
||||
update_local_markdown_title, update_local_page_options, write_local_markdown_page_body,
|
||||
};
|
||||
pub(crate) use local_search_index::refresh_local_search_index_for_path;
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
|
||||
Reference in New Issue
Block a user