Improve local evidence search and AI capabilities
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user