Improve local evidence search and AI capabilities

This commit is contained in:
lix-2026
2026-06-05 23:00:53 +08:00
parent a1dcfc9f76
commit 4dbd9a978b
52 changed files with 6415 additions and 527 deletions
@@ -15,6 +15,7 @@ use core_protocol::{
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -134,13 +135,19 @@ pub(crate) fn query_local_search_index_with_settings(
)?;
let normalized_query = normalize_search_text(query);
let page_id = page_id.map(str::trim).filter(|value| !value.is_empty());
let page_resource_path = page_id.and_then(local_resource_path_from_document_id);
let markdown_page_id = if page_resource_path.is_some() {
None
} else {
page_id
};
let recent_changes = local_recent_changes_projection(&index.documents, root_uri);
let mut results = Vec::new();
for document in index.documents.iter() {
if !index_relative_path_is_included(&document.path, &result_settings.include_paths) {
continue;
}
if let Some(page_id) = page_id {
if let Some(page_id) = markdown_page_id {
if document.document_id != page_id {
continue;
}
@@ -157,11 +164,16 @@ pub(crate) fn query_local_search_index_with_settings(
break;
}
}
if page_id.is_none() && results.len() < limit.max(1) as usize {
if markdown_page_id.is_none() && results.len() < limit.max(1) as usize {
for resource in index.resources.iter() {
if !index_relative_path_is_included(&resource.path, &result_settings.include_paths) {
continue;
}
if let Some(resource_path) = page_resource_path.as_deref() {
if resource.path != resource_path {
continue;
}
}
if !local_search_resource_matches(resource, &normalized_query, title_only, exact) {
continue;
}
@@ -180,7 +192,11 @@ pub(crate) fn query_local_search_index_with_settings(
continue;
}
if let Some(page_id) = page_id {
if entry.owner_document_id != page_id {
if let Some(resource_path) = page_resource_path.as_deref() {
if entry.source_root_relative_path != resource_path {
continue;
}
} else if entry.owner_document_id != page_id {
continue;
}
}
@@ -364,6 +380,43 @@ pub(crate) fn write_user_local_index_settings(
Ok(settings)
}
pub(crate) fn preview_user_local_index_settings(
store: &dyn ControlPlaneStore,
user_id: &str,
workspace_id: &str,
root_path: &Path,
include_paths: &[String],
schedule_mode: Option<&str>,
schedule_time: Option<&str>,
schedule_date: Option<&str>,
run_on_change: Option<bool>,
) -> Result<LocalIndexSettings, WebError> {
let user_id = user_id.trim();
if user_id.is_empty() || user_id == "anonymous" {
return Err(WebError::bad_request_code(
"local_index_settings_auth_required",
"本地索引设置需要登录用户",
));
}
let existing = read_user_local_index_settings(store, user_id, workspace_id, root_path)?;
let include_paths = normalize_index_include_paths(root_path, include_paths)?;
let schedule_mode =
normalize_index_schedule_mode(schedule_mode.unwrap_or(&existing.schedule_mode))?;
let schedule_time =
normalize_index_schedule_time(schedule_time.unwrap_or(&existing.schedule_time))?;
let schedule_date =
normalize_index_schedule_date(schedule_date.or(existing.schedule_date.as_deref()))?;
Ok(LocalIndexSettings {
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
include_paths,
schedule_mode,
schedule_time,
schedule_date,
run_on_change: run_on_change.unwrap_or(existing.run_on_change),
updated_at: now_ms(),
})
}
pub(crate) fn effective_local_index_settings_for_root(
store: &dyn ControlPlaneStore,
workspace_id: &str,
@@ -452,7 +505,7 @@ fn local_index_status_for_settings(
let mut document_count = 0usize;
let mut resource_count = 0usize;
let mut built_at = Value::Null;
let mut cache_matches_settings = false;
let cache_matches_settings;
let scheduled_due = if let Some(index) = index.as_ref() {
document_count = index.documents.len();
resource_count = index.resources.len();
@@ -463,6 +516,7 @@ fn local_index_status_for_settings(
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths;
local_index_schedule_is_due(&settings, index.built_at)
} else {
cache_matches_settings = settings.include_paths.is_empty();
local_index_schedule_is_due(&settings, 0)
};
let evidence_block_count = if evidence_path.exists() {
@@ -622,6 +676,16 @@ pub(crate) fn query_evidence_sqlite_results(
query: &str,
owner_document_id: Option<&str>,
limit: u32,
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
query_evidence_sqlite_results_with_mode(root_path, query, owner_document_id, limit, true)
}
pub(crate) fn query_evidence_sqlite_results_with_mode(
root_path: &Path,
query: &str,
owner_document_id: Option<&str>,
limit: u32,
exact: bool,
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
let path = evidence_sqlite_path(root_path);
if !path.exists() {
@@ -631,24 +695,54 @@ pub(crate) fn query_evidence_sqlite_results(
if normalized_query.is_empty() {
return Ok(Some(Vec::new()));
}
let owner_document_id = owner_document_id
.map(str::trim)
.filter(|value| !value.is_empty());
let resource_path = owner_document_id.and_then(local_resource_path_from_document_id);
let owner_document_id = if resource_path.is_some() {
None
} else {
owner_document_id
};
let connection = Connection::open(&path).map_err(|error| {
WebError::bad_request_code(
"evidence_index_open_failed",
format!("无法打开 evidence 索引 {}: {error}", path.display()),
)
})?;
if !exact {
return Ok(Some(query_evidence_sqlite_fuzzy(
&connection,
normalized_query,
owner_document_id,
resource_path.as_deref(),
limit,
)?));
}
let fts_query = evidence_fts_phrase(normalized_query);
let results = match query_evidence_sqlite_fts(
&connection,
&fts_query,
normalized_query,
owner_document_id,
resource_path.as_deref(),
limit,
) {
Ok(results) if results.is_empty() => query_evidence_sqlite_like(
&connection,
normalized_query,
owner_document_id,
resource_path.as_deref(),
limit,
)?,
Ok(results) => results,
Err(_) => {
query_evidence_sqlite_like(&connection, normalized_query, owner_document_id, limit)?
}
Err(_) => query_evidence_sqlite_like(
&connection,
normalized_query,
owner_document_id,
resource_path.as_deref(),
limit,
)?,
};
Ok(Some(results))
}
@@ -718,6 +812,9 @@ pub(crate) fn read_evidence_sqlite_context(
0.8
},
source: source.clone(),
citation_url: None,
citation_label: None,
citation_markdown: None,
})
.collect::<Vec<_>>();
Ok(Some(results))
@@ -804,6 +901,9 @@ fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchRes
block_id: source.block_id.or(Some(source_block_id)),
..source
},
citation_url: None,
citation_label: None,
citation_markdown: None,
})
}
@@ -824,6 +924,7 @@ fn query_evidence_sqlite_fts(
fts_query: &str,
display_query: &str,
owner_document_id: Option<&str>,
resource_path: Option<&str>,
limit: u32,
) -> Result<Vec<EvidenceSearchResult>, WebError> {
let mut sql = String::from(
@@ -835,8 +936,10 @@ fn query_evidence_sqlite_fts(
);
if owner_document_id.is_some() {
sql.push_str(" AND r.owner_document_id = ?2");
} else if resource_path.is_some() {
sql.push_str(" AND r.source_root_relative_path = ?2");
}
sql.push_str(if owner_document_id.is_some() {
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
" ORDER BY rank LIMIT ?3"
} else {
" ORDER BY rank LIMIT ?2"
@@ -850,6 +953,13 @@ fn query_evidence_sqlite_fts(
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
} else if let Some(resource_path) = resource_path {
statement
.query_map(params![fts_query, resource_path, limit], |row| {
evidence_result_from_sqlite_row(row, display_query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
} else {
statement
.query_map(params![fts_query, limit], |row| {
@@ -865,6 +975,7 @@ fn query_evidence_sqlite_like(
connection: &Connection,
query: &str,
owner_document_id: Option<&str>,
resource_path: Option<&str>,
limit: u32,
) -> Result<Vec<EvidenceSearchResult>, WebError> {
let mut sql = String::from(
@@ -875,8 +986,10 @@ fn query_evidence_sqlite_like(
);
if owner_document_id.is_some() {
sql.push_str(" AND r.owner_document_id = ?2");
} else if resource_path.is_some() {
sql.push_str(" AND r.source_root_relative_path = ?2");
}
sql.push_str(if owner_document_id.is_some() {
sql.push_str(if owner_document_id.is_some() || resource_path.is_some() {
" LIMIT ?3"
} else {
" LIMIT ?2"
@@ -891,6 +1004,13 @@ fn query_evidence_sqlite_like(
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
} else if let Some(resource_path) = resource_path {
statement
.query_map(params![like_query, resource_path, limit], |row| {
evidence_result_from_sqlite_row(row, query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
} else {
statement
.query_map(params![like_query, limit], |row| {
@@ -902,6 +1022,80 @@ fn query_evidence_sqlite_like(
rows.map_err(sqlite_error)
}
fn query_evidence_sqlite_fuzzy(
connection: &Connection,
query: &str,
owner_document_id: Option<&str>,
resource_path: Option<&str>,
limit: u32,
) -> Result<Vec<EvidenceSearchResult>, WebError> {
let mut sql = String::from(
"SELECT b.block_id, b.text, b.locator_json, 0.0 AS rank \
FROM evidence_block b \
JOIN evidence_resource r ON r.resource_id = b.resource_id",
);
let first_char_like = query
.chars()
.find(|ch| !ch.is_whitespace())
.map(|ch| format!("%{}%", ch));
match (
owner_document_id.is_some() || resource_path.is_some(),
first_char_like.is_some(),
resource_path.is_some(),
) {
(true, true, true) => {
sql.push_str(" WHERE r.source_root_relative_path = ?1 AND b.text LIKE ?2 LIMIT ?3")
}
(true, true, false) => {
sql.push_str(" WHERE r.owner_document_id = ?1 AND b.text LIKE ?2 LIMIT ?3")
}
(true, false, true) => sql.push_str(" WHERE r.source_root_relative_path = ?1 LIMIT ?2"),
(true, false, false) => sql.push_str(" WHERE r.owner_document_id = ?1 LIMIT ?2"),
(false, true, _) => sql.push_str(" WHERE b.text LIKE ?1 LIMIT ?2"),
(false, false, _) => sql.push_str(" LIMIT ?1"),
}
let mut statement = connection.prepare(&sql).map_err(sqlite_error)?;
let scan_limit = i64::from(limit.max(1)) * 200;
let scope_value = owner_document_id.or(resource_path);
let rows = match (scope_value, first_char_like.as_deref()) {
(Some(scope_value), Some(first_char_like)) => statement
.query_map(params![scope_value, first_char_like, scan_limit], |row| {
evidence_result_from_sqlite_row(row, query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>(),
(Some(scope_value), None) => statement
.query_map(params![scope_value, scan_limit], |row| {
evidence_result_from_sqlite_row(row, query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>(),
(None, Some(first_char_like)) => statement
.query_map(params![first_char_like, scan_limit], |row| {
evidence_result_from_sqlite_row(row, query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>(),
(None, None) => statement
.query_map(params![scan_limit], |row| {
evidence_result_from_sqlite_row(row, query)
})
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>(),
}
.map_err(sqlite_error)?;
Ok(rows
.into_iter()
.filter(|result| {
fuzzy_search_match(
&normalize_search_text(&result.quote),
&normalize_search_text(query),
)
})
.take(limit.max(1) as usize)
.collect())
}
fn evidence_result_from_sqlite_row(
row: &rusqlite::Row<'_>,
query: &str,
@@ -922,6 +1116,9 @@ fn evidence_result_from_sqlite_row(
1.0 / (1.0 + rank.abs())
},
source,
citation_url: None,
citation_label: None,
citation_markdown: None,
})
}
@@ -945,6 +1142,18 @@ pub(crate) fn refresh_local_search_index_with_settings(
workspace_id: &str,
settings: &LocalIndexSettings,
) -> Result<Value, WebError> {
if settings.include_paths.is_empty() {
clear_local_search_index(root_path)?;
return Ok(json!({
"version": LOCAL_SEARCH_INDEX_VERSION,
"rootUri": root_uri,
"workspaceId": workspace_id,
"indexedPaths": [],
"builtAt": now_ms(),
"documentCount": 0,
"resourceCount": 0
}));
}
let index =
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, settings)?;
Ok(json!({
@@ -1427,7 +1636,7 @@ fn parse_local_index_settings_value(
fn default_local_index_settings() -> LocalIndexSettings {
LocalIndexSettings {
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
include_paths: default_indexed_paths(),
include_paths: Vec::new(),
schedule_mode: default_index_schedule_mode(),
schedule_time: default_index_schedule_time(),
schedule_date: None,
@@ -1605,11 +1814,46 @@ fn normalize_index_include_paths(
if trimmed.is_empty() {
continue;
}
let raw_path = PathBuf::from(trimmed);
let normalized = if trimmed == "." || trimmed == "/" {
".".to_string()
} else if raw_path.is_absolute() {
let canonical = raw_path.canonicalize().map_err(|error| {
WebError::bad_request_code(
"local_index_scope_not_found",
format!("本地索引范围不存在 {}: {error}", raw_path.display()),
)
})?;
if !canonical.starts_with(&root_canonical) {
return Err(WebError::bad_request_code(
"local_index_scope_escape",
"本地索引目录必须位于当前授权 root 内",
));
}
if !canonical.is_dir() {
return Err(WebError::bad_request_code(
"local_index_scope_not_directory",
"本地索引范围必须是目录",
));
}
canonical
.strip_prefix(&root_canonical)
.map_err(|_| {
WebError::bad_request_code(
"local_index_scope_escape",
"本地索引目录必须位于当前授权 root 内",
)
})?
.to_string_lossy()
.replace('\\', "/")
} else {
normalize_index_relative_path(trimmed.trim_start_matches("./"))?
};
let normalized = if normalized.is_empty() {
".".to_string()
} else {
normalized
};
if normalized.split('/').any(|part| part == ".mnote") {
return Err(WebError::bad_request_code(
"local_index_scope_reserved",
@@ -1640,9 +1884,6 @@ fn normalize_index_include_paths(
}
output.push(normalized);
}
if output.is_empty() {
output.push(".".to_string());
}
Ok(normalized_indexed_paths(&output))
}
@@ -1884,6 +2125,27 @@ fn write_local_search_index_json(
})
}
fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
let index_path = root_path
.join(".mnote")
.join("index")
.join("search-index.json");
let evidence_path = evidence_sqlite_path(root_path);
for path in [index_path, evidence_path] {
match fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
));
}
}
}
Ok(())
}
fn ensure_evidence_sqlite_index(
root_path: &Path,
index: &LocalSearchIndex,
@@ -3151,6 +3413,72 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
.map_err(sqlite_error)
}
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalEvidenceSourceStatuses {
pub(crate) indexed_paths: BTreeSet<String>,
pub(crate) failed_paths: BTreeSet<String>,
}
pub(crate) fn local_evidence_source_statuses(
root_path: &Path,
) -> Result<LocalEvidenceSourceStatuses, WebError> {
let mut statuses = LocalEvidenceSourceStatuses::default();
let evidence_path = evidence_sqlite_path(root_path);
if evidence_path.exists() {
let connection = Connection::open(&evidence_path).map_err(|error| {
WebError::bad_request_code(
"evidence_index_open_failed",
format!(
"无法打开 evidence 索引 {}: {error}",
evidence_path.display()
),
)
})?;
let mut statement = connection
.prepare(
"SELECT DISTINCT source_root_relative_path \
FROM evidence_resource \
WHERE provider NOT IN ('resource', 'markdown')",
)
.map_err(sqlite_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(sqlite_error)?;
for row in rows {
if let Ok(path) = row {
if !path.trim().is_empty() {
statuses.indexed_paths.insert(path);
}
}
}
}
if let Some(index) = read_local_search_index(root_path)? {
for resource in index.resources {
if matches!(resource.resource_type.as_str(), "pdf" | "office")
&& !statuses.indexed_paths.contains(&resource.path)
{
statuses.failed_paths.insert(resource.path);
}
}
}
for entry in local_ocr::ocr_index_entries(root_path)? {
match entry.status.as_str() {
"done" => {
statuses
.indexed_paths
.insert(entry.source_root_relative_path);
}
"failed" | "interrupted" => {
statuses
.failed_paths
.insert(entry.source_root_relative_path);
}
_ => {}
}
}
Ok(statuses)
}
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
match resource_type {
"mindmap" => "mindmap",
@@ -3179,6 +3507,14 @@ fn evidence_resource_kind_for_path(path: &str) -> &'static str {
}
}
fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
document_id
.trim()
.strip_prefix("local-resource:")
.map(|value| value.replace("~2F", "/").replace("~2f", "/"))
.filter(|value| !value.trim().is_empty())
}
fn path_source_map_path(path: &str) -> Option<String> {
if let Some(stripped) = path.strip_suffix(".ocr.md") {
return Some(format!("{stripped}.source-map.json"));
@@ -3267,9 +3603,9 @@ fn local_search_document_matches(
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
} else {
fuzzy_search_match(&haystack, query)
}
}
@@ -3291,9 +3627,9 @@ fn local_search_resource_matches(
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
} else {
fuzzy_search_match(&haystack, query)
}
}
@@ -3319,9 +3655,9 @@ fn local_search_ocr_matches(
))
};
if exact {
haystack == query
} else {
haystack.contains(query)
} else {
fuzzy_search_match(&haystack, query)
}
}
@@ -3330,6 +3666,7 @@ fn local_search_document_projection(
root_uri: &str,
query: &str,
) -> Value {
let hit = search_document_hit(document, query);
json!({
"id": document.document_id,
"documentId": document.document_id,
@@ -3338,7 +3675,12 @@ fn local_search_document_projection(
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"snippet": search_snippet(document, query),
"snippet": hit.snippet,
"blockId": hit.block_id,
"lineRange": {
"start": hit.line_number,
"end": hit.line_number,
},
"tags": document.tags,
"backlinks": document.backlinks,
"resourceRefs": document.resource_refs,
@@ -3548,16 +3890,43 @@ fn is_local_resource_reference(target: &str) -> bool {
}
fn search_snippet(document: &LocalSearchDocument, query: &str) -> String {
for line in document.raw_text.lines() {
search_document_hit(document, query).snippet
}
#[derive(Debug, Clone)]
struct SearchDocumentHit {
snippet: String,
line_number: usize,
block_id: String,
}
fn search_document_hit(document: &LocalSearchDocument, query: &str) -> SearchDocumentHit {
for (line_index, line) in document.raw_text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if normalize_search_text(trimmed).contains(query) {
return trimmed.chars().take(180).collect();
let line_number = line_index + 1;
return SearchDocumentHit {
snippet: trimmed.chars().take(180).collect(),
line_number,
block_id: format!("{}#line{}", document.document_id, line_number),
};
}
}
document.raw_text.chars().take(180).collect()
let line_number = document
.raw_text
.lines()
.enumerate()
.find(|(_, line)| !line.trim().is_empty())
.map(|(line_index, _)| line_index + 1)
.unwrap_or(1);
SearchDocumentHit {
snippet: document.raw_text.chars().take(180).collect(),
line_number,
block_id: format!("{}#line{}", document.document_id, line_number),
}
}
fn ocr_search_snippet(body: &str, query: &str) -> String {
@@ -3576,11 +3945,73 @@ fn ocr_search_snippet(body: &str, query: &str) -> String {
.map(|(idx, _)| idx)
.unwrap_or(0);
normalized_body[start..].chars().take(160).collect()
} else if let Some(byte_index) = fuzzy_search_start_byte(&normalized_body, normalized_query) {
let start = normalized_body[..byte_index]
.char_indices()
.rev()
.nth(40)
.map(|(idx, _)| idx)
.unwrap_or(0);
normalized_body[start..].chars().take(180).collect()
} else {
normalized_body.chars().take(160).collect()
}
}
fn fuzzy_search_match(haystack: &str, query: &str) -> bool {
if query.is_empty() {
return false;
}
if haystack.contains(query) {
return true;
}
let mut query_chars = query.chars().filter(|ch| !ch.is_whitespace());
let Some(mut wanted) = query_chars.next() else {
return false;
};
for ch in haystack.chars().filter(|ch| !ch.is_whitespace()) {
if ch == wanted {
match query_chars.next() {
Some(next) => wanted = next,
None => return true,
}
}
}
false
}
fn fuzzy_search_start_byte(haystack: &str, query: &str) -> Option<usize> {
if query.is_empty() {
return None;
}
let query_chars = query
.chars()
.filter(|ch| !ch.is_whitespace())
.collect::<Vec<_>>();
if query_chars.is_empty() {
return None;
}
let haystack_chars = haystack.char_indices().collect::<Vec<_>>();
for (start_index, (byte_index, ch)) in haystack_chars.iter().enumerate() {
if ch != &query_chars[0] {
continue;
}
let mut query_index = 1usize;
for (_, next_ch) in haystack_chars.iter().skip(start_index + 1) {
if next_ch.is_whitespace() {
continue;
}
if query_index < query_chars.len() && next_ch == &query_chars[query_index] {
query_index += 1;
if query_index >= query_chars.len() {
return Some(*byte_index);
}
}
}
}
None
}
fn is_markdown_path(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
@@ -3905,6 +4336,89 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_index_settings_accepts_absolute_path_under_root_as_frozen_scope() {
let root = temp_root("mnote-local-index-settings-absolute");
fs::create_dir_all(root.join("docs").join("absolute")).expect("create absolute dir");
let absolute_scope = root.join("docs").join("absolute");
let settings = write_local_index_settings(
&root,
&[absolute_scope.to_string_lossy().to_string()],
None,
None,
None,
None,
)
.expect("absolute scope under root");
assert_eq!(settings.include_paths, vec![String::from("docs/absolute")]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn user_local_index_empty_scope_deletes_index_files() {
let root = temp_root("mnote-local-index-empty-delete");
fs::write(
root.join("docs").join("keep.md"),
"# Keep\nDeleteIndexToken\n",
)
.expect("write doc");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-empty-delete";
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
write_user_local_index_settings(
&store,
"alice",
workspace_id,
&root,
&[String::from("docs")],
Some("manual"),
Some("02:00"),
None,
Some(false),
)
.expect("write indexed scope");
let effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
.expect("effective indexed");
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &effective)
.expect("refresh indexed");
assert!(root.join(".mnote/index/search-index.json").exists());
assert!(root.join(".mnote/index/evidence.sqlite").exists());
write_user_local_index_settings(
&store,
"alice",
workspace_id,
&root,
&[],
Some("manual"),
Some("02:00"),
None,
Some(false),
)
.expect("delete indexed scopes");
let empty_effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
.expect("effective empty");
assert!(empty_effective.include_paths.is_empty());
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &empty_effective)
.expect("clear index files");
assert!(!root.join(".mnote/index/search-index.json").exists());
assert!(!root.join(".mnote/index/evidence.sqlite").exists());
let status = local_index_status_with_settings(
&root,
&root_uri,
workspace_id,
&empty_effective,
&empty_effective,
)
.expect("status");
assert_eq!(status["cacheMatchesSettings"].as_bool(), Some(true));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn local_index_settings_rejects_escape_and_internal_cache_scope() {
let root = temp_root("mnote-local-index-settings-escape");
@@ -3949,6 +4463,14 @@ mod tests {
initial_status["settings"]["runOnChange"].as_bool(),
Some(false)
);
assert_eq!(
initial_status["settings"]["includePaths"]
.as_array()
.map(Vec::len),
Some(0),
"默认不应索引工作区根目录;用户新增范围后才开始索引"
);
assert_eq!(initial_status["cacheMatchesSettings"].as_bool(), Some(true));
let settings = write_local_index_settings(
&root,
@@ -4356,6 +4878,8 @@ mod tests {
)
.expect("write child");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("initial refresh");
fs::write(
root.join("docs").join("child.md"),
@@ -4643,6 +5167,8 @@ mod tests {
)
.expect("write child");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
let results = query_evidence_sqlite_results(&root, "EvidenceToken", None, 10)
@@ -4750,6 +5276,8 @@ mod tests {
)
.expect("write home");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
let results = query_evidence_sqlite_results(&root, "ReadContextToken", None, 10)
.expect("sqlite query")
@@ -4821,6 +5349,8 @@ JSON
let old_bin = std::env::var("MNOTE_LITEPARSE_BIN").ok();
std::env::set_var("MNOTE_LITEPARSE_BIN", &lit);
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
refresh_local_search_index(&root, &root_uri, workspace_id).expect("refresh");
if let Some(value) = old_bin {
@@ -4850,6 +5380,21 @@ JSON
hit.source.source_map_path.as_deref(),
Some("docs/Page.ocr/spec.pdf.source-map.json")
);
let resource_scoped = query_evidence_sqlite_results_with_mode(
&root,
"ResourceBodyToken",
Some("local-resource:docs~2FPage.assets~2Fspec.pdf"),
10,
true,
)
.expect("resource scoped sqlite query")
.expect("sqlite exists");
assert_eq!(resource_scoped.len(), 1);
assert_eq!(
resource_scoped[0].source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
);
assert!(root
.join("docs")
.join("Page.ocr")