5374 lines
179 KiB
Rust
5374 lines
179 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 control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
|
||
use core_protocol::{
|
||
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
|
||
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
|
||
};
|
||
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};
|
||
use time::{Date, Duration, Month, OffsetDateTime, Time};
|
||
|
||
const LOCAL_SEARCH_INDEX_VERSION: u32 = 1;
|
||
const LOCAL_INDEX_SETTINGS_SCHEMA: &str = "mnote.local_index.settings.v1";
|
||
const LOCAL_INDEX_SETTINGS_KEY: &str = "localIndex.settings";
|
||
const LOCAL_INDEX_SETTINGS_SCOPE_KIND: &str = "localIndex";
|
||
const LOCAL_INDEX_SOURCE_KIND: &str = "local_folder";
|
||
const DEFAULT_INDEX_SCHEDULE_MODE: &str = "daily";
|
||
const DEFAULT_INDEX_SCHEDULE_TIME: &str = "02:00";
|
||
const EVIDENCE_SQLITE_SCHEMA_VERSION: u32 = 1;
|
||
|
||
fn local_index_scope_id(root_path: &Path) -> String {
|
||
root_path.to_string_lossy().replace('\\', "/")
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalSearchIndex {
|
||
version: u32,
|
||
built_at: u128,
|
||
root_uri: String,
|
||
workspace_id: String,
|
||
#[serde(default = "default_indexed_paths")]
|
||
indexed_paths: Vec<String>,
|
||
documents: Vec<LocalSearchDocument>,
|
||
#[serde(default)]
|
||
resources: Vec<LocalSearchResource>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub(crate) struct LocalIndexSettings {
|
||
pub schema: String,
|
||
pub include_paths: Vec<String>,
|
||
#[serde(default = "default_index_schedule_mode")]
|
||
pub schedule_mode: String,
|
||
#[serde(default = "default_index_schedule_time")]
|
||
pub schedule_time: String,
|
||
#[serde(default)]
|
||
pub schedule_date: Option<String>,
|
||
#[serde(default = "default_index_run_on_change")]
|
||
pub run_on_change: bool,
|
||
pub updated_at: u128,
|
||
}
|
||
|
||
#[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 settings = read_local_index_settings_or_default(root_path)?;
|
||
query_local_search_index_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
&settings,
|
||
&settings,
|
||
query,
|
||
page_id,
|
||
limit,
|
||
title_only,
|
||
exact,
|
||
include_ocr,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn query_local_search_index_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
cache_settings: &LocalIndexSettings,
|
||
result_settings: &LocalIndexSettings,
|
||
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_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
cache_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) = markdown_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 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;
|
||
}
|
||
results.push(local_search_resource_projection(resource, root_uri));
|
||
if results.len() >= limit.max(1) as usize {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
let _ = include_ocr;
|
||
Ok(json!({
|
||
"enqueueAssetIds": [],
|
||
"projectionOwner": "rust-kernel",
|
||
"sourceKind": "local_folder",
|
||
"index": {
|
||
"version": index.version,
|
||
"rootUri": index.root_uri,
|
||
"workspaceId": index.workspace_id,
|
||
"indexedPaths": index.indexed_paths,
|
||
"resultIndexedPaths": result_settings.include_paths,
|
||
"builtAt": index.built_at,
|
||
"documentCount": index.documents.len(),
|
||
"resourceCount": index.resources.len(),
|
||
"includeOcr": include_ocr
|
||
},
|
||
"recentChanges": recent_changes,
|
||
"results": results
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn write_local_index_settings(
|
||
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 include_paths = normalize_index_include_paths(root_path, include_paths)?;
|
||
let existing = read_local_index_settings_or_default(root_path)
|
||
.unwrap_or_else(|_| default_local_index_settings());
|
||
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()))?;
|
||
let settings = 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(),
|
||
};
|
||
write_local_index_settings_cache(root_path, &settings)?;
|
||
Ok(settings)
|
||
}
|
||
|
||
pub(crate) fn read_user_local_index_settings(
|
||
store: &dyn ControlPlaneStore,
|
||
user_id: &str,
|
||
workspace_id: &str,
|
||
root_path: &Path,
|
||
) -> Result<LocalIndexSettings, WebError> {
|
||
let user_id = user_id.trim();
|
||
let scope_id = local_index_scope_id(root_path);
|
||
if user_id.is_empty() || user_id == "anonymous" {
|
||
return Ok(read_local_index_settings_or_default(root_path)
|
||
.unwrap_or_else(|_| default_local_index_settings()));
|
||
}
|
||
let root_preferences = store
|
||
.list_user_ui_preferences_for_scope(
|
||
Some(workspace_id),
|
||
Some(LOCAL_INDEX_SOURCE_KIND),
|
||
LOCAL_INDEX_SETTINGS_SCOPE_KIND,
|
||
&scope_id,
|
||
LOCAL_INDEX_SETTINGS_KEY,
|
||
)
|
||
.map_err(|error| WebError::internal(format!("SQLite 本地索引设置读取失败: {error}")))?;
|
||
let preferences = store
|
||
.list_user_ui_preferences(user_id, Some(workspace_id), Some(LOCAL_INDEX_SOURCE_KIND))
|
||
.map_err(|error| WebError::internal(format!("SQLite 本地索引设置读取失败: {error}")))?;
|
||
let Some(record) = preferences.iter().rev().find(|preference| {
|
||
preference.scope_kind == LOCAL_INDEX_SETTINGS_SCOPE_KIND
|
||
&& preference.scope_id == scope_id
|
||
&& preference.key == LOCAL_INDEX_SETTINGS_KEY
|
||
}) else {
|
||
if root_preferences.is_empty() {
|
||
return Ok(read_local_index_settings_or_default(root_path)
|
||
.unwrap_or_else(|_| default_local_index_settings()));
|
||
}
|
||
return Ok(default_local_index_settings());
|
||
};
|
||
parse_local_index_settings_value(root_path, &record.value_json)
|
||
}
|
||
|
||
pub(crate) fn write_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();
|
||
let scope_id = local_index_scope_id(root_path);
|
||
if user_id.is_empty() || user_id == "anonymous" {
|
||
return Err(WebError::bad_request_code(
|
||
"local_index_settings_auth_required",
|
||
"本地索引设置需要登录用户",
|
||
));
|
||
}
|
||
store
|
||
.upsert_user(UpsertUserInput {
|
||
id: Some(user_id.to_string()),
|
||
email: None,
|
||
username: user_id.to_string(),
|
||
display_name: user_id.to_string(),
|
||
role: None,
|
||
password_hash: None,
|
||
})
|
||
.map_err(|error| WebError::internal(format!("SQLite 用户初始化失败: {error}")))?;
|
||
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()))?;
|
||
let settings = 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(),
|
||
};
|
||
let value_json = serde_json::to_string(&settings)
|
||
.map_err(|error| WebError::internal(format!("本地索引设置序列化失败: {error}")))?;
|
||
store
|
||
.upsert_user_ui_preference(UpsertUserUiPreferenceInput {
|
||
id: None,
|
||
user_id: user_id.to_string(),
|
||
workspace_id: Some(workspace_id.to_string()),
|
||
source_kind: Some(LOCAL_INDEX_SOURCE_KIND.to_string()),
|
||
scope_kind: LOCAL_INDEX_SETTINGS_SCOPE_KIND.to_string(),
|
||
scope_id,
|
||
key: LOCAL_INDEX_SETTINGS_KEY.to_string(),
|
||
value_json,
|
||
})
|
||
.map_err(|error| WebError::internal(format!("SQLite 本地索引设置写入失败: {error}")))?;
|
||
let effective = effective_local_index_settings_for_root(store, workspace_id, root_path)?;
|
||
write_local_index_settings_cache(root_path, &effective)?;
|
||
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,
|
||
root_path: &Path,
|
||
) -> Result<LocalIndexSettings, WebError> {
|
||
let scope_id = local_index_scope_id(root_path);
|
||
let records = store
|
||
.list_user_ui_preferences_for_scope(
|
||
Some(workspace_id),
|
||
Some(LOCAL_INDEX_SOURCE_KIND),
|
||
LOCAL_INDEX_SETTINGS_SCOPE_KIND,
|
||
&scope_id,
|
||
LOCAL_INDEX_SETTINGS_KEY,
|
||
)
|
||
.map_err(|error| WebError::internal(format!("SQLite 本地索引设置读取失败: {error}")))?;
|
||
let mut effective = read_local_index_settings_or_default(root_path)
|
||
.unwrap_or_else(|_| default_local_index_settings());
|
||
if records.is_empty() {
|
||
return Ok(effective);
|
||
}
|
||
let mut include_paths = Vec::new();
|
||
let mut run_on_change = false;
|
||
for record in records {
|
||
let settings = parse_local_index_settings_value(root_path, &record.value_json)?;
|
||
include_paths.extend(settings.include_paths);
|
||
run_on_change |= settings.run_on_change;
|
||
}
|
||
effective.include_paths = normalize_index_include_paths(root_path, &include_paths)?;
|
||
effective.run_on_change = run_on_change;
|
||
effective.updated_at = now_ms();
|
||
Ok(effective)
|
||
}
|
||
|
||
pub(crate) fn local_index_any_schedule_due_for_root(
|
||
store: &dyn ControlPlaneStore,
|
||
workspace_id: &str,
|
||
root_path: &Path,
|
||
built_at_ms: u128,
|
||
) -> Result<bool, WebError> {
|
||
let scope_id = local_index_scope_id(root_path);
|
||
let records = store
|
||
.list_user_ui_preferences_for_scope(
|
||
Some(workspace_id),
|
||
Some(LOCAL_INDEX_SOURCE_KIND),
|
||
LOCAL_INDEX_SETTINGS_SCOPE_KIND,
|
||
&scope_id,
|
||
LOCAL_INDEX_SETTINGS_KEY,
|
||
)
|
||
.map_err(|error| WebError::internal(format!("SQLite 本地索引设置读取失败: {error}")))?;
|
||
if records.is_empty() {
|
||
let settings = read_local_index_settings_or_default(root_path)
|
||
.unwrap_or_else(|_| default_local_index_settings());
|
||
return Ok(local_index_schedule_is_due(&settings, built_at_ms));
|
||
}
|
||
for record in records {
|
||
let settings = parse_local_index_settings_value(root_path, &record.value_json)?;
|
||
if local_index_schedule_is_due(&settings, built_at_ms) {
|
||
return Ok(true);
|
||
}
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn local_index_status(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Value, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
local_index_status_for_settings(root_path, root_uri, workspace_id, &settings)
|
||
}
|
||
|
||
fn local_index_status_for_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
settings: &LocalIndexSettings,
|
||
) -> Result<Value, WebError> {
|
||
let index = read_local_search_index(root_path)?;
|
||
let index_path = root_path
|
||
.join(".mnote")
|
||
.join("index")
|
||
.join("search-index.json");
|
||
let evidence_path = evidence_sqlite_path(root_path);
|
||
let mut document_count = 0usize;
|
||
let mut resource_count = 0usize;
|
||
let mut built_at = Value::Null;
|
||
let cache_matches_settings;
|
||
let scheduled_due = if let Some(index) = index.as_ref() {
|
||
document_count = index.documents.len();
|
||
resource_count = index.resources.len();
|
||
built_at = json!(index.built_at);
|
||
cache_matches_settings = index.version == LOCAL_SEARCH_INDEX_VERSION
|
||
&& index.root_uri == root_uri
|
||
&& index.workspace_id == workspace_id
|
||
&& 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() {
|
||
count_evidence_blocks(&evidence_path).unwrap_or(0)
|
||
} else {
|
||
0
|
||
};
|
||
Ok(json!({
|
||
"schema": "mnote.local_index.status.v1",
|
||
"rootUri": root_uri,
|
||
"workspaceId": workspace_id,
|
||
"settings": settings,
|
||
"indexPath": ".mnote/index/search-index.json",
|
||
"evidenceIndexPath": ".mnote/index/evidence.sqlite",
|
||
"settingsPath": ".mnote/index/local-index-settings.json",
|
||
"indexExists": index_path.exists(),
|
||
"evidenceIndexExists": evidence_path.exists(),
|
||
"cacheMatchesSettings": cache_matches_settings,
|
||
"scheduledDue": scheduled_due,
|
||
"builtAt": built_at,
|
||
"indexedPaths": index
|
||
.as_ref()
|
||
.map(|index| index.indexed_paths.clone())
|
||
.unwrap_or_default(),
|
||
"documentCount": document_count,
|
||
"resourceCount": resource_count,
|
||
"evidenceBlockCount": evidence_block_count,
|
||
}))
|
||
}
|
||
|
||
pub(crate) fn local_index_status_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
user_settings: &LocalIndexSettings,
|
||
effective_settings: &LocalIndexSettings,
|
||
) -> Result<Value, WebError> {
|
||
let mut status =
|
||
local_index_status_for_settings(root_path, root_uri, workspace_id, effective_settings)?;
|
||
if let Some(object) = status.as_object_mut() {
|
||
object.insert("settings".to_string(), json!(user_settings));
|
||
object.insert("effectiveSettings".to_string(), json!(effective_settings));
|
||
}
|
||
Ok(status)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn local_index_should_refresh_on_change(root_path: &Path) -> bool {
|
||
read_local_index_settings_or_default(root_path)
|
||
.map(|settings| settings.run_on_change)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn refresh_local_search_index_for_change(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
if !local_index_should_refresh_on_change(root_path) {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index(root_path, root_uri, workspace_id).map(Some)
|
||
}
|
||
|
||
pub(crate) fn refresh_local_search_index_for_change_with_store(
|
||
store: &dyn ControlPlaneStore,
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
let effective_settings =
|
||
effective_local_index_settings_for_root(store, workspace_id, root_path)?;
|
||
if !effective_settings.run_on_change {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index_with_settings(root_path, root_uri, workspace_id, &effective_settings)
|
||
.map(Some)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn refresh_local_search_index_for_change_path(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
relative_path: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
if !local_index_should_refresh_on_change(root_path) {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index_for_path(root_path, root_uri, workspace_id, relative_path).map(Some)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn refresh_local_search_index_if_scheduled_due(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
let Some(index) = read_local_search_index(root_path)? else {
|
||
if local_index_schedule_is_due(&settings, 0) {
|
||
return refresh_local_search_index(root_path, root_uri, workspace_id).map(Some);
|
||
}
|
||
return Ok(None);
|
||
};
|
||
if !local_index_schedule_is_due(&settings, index.built_at) {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index(root_path, root_uri, workspace_id).map(Some)
|
||
}
|
||
|
||
pub(crate) fn refresh_local_search_index_for_change_path_with_store(
|
||
store: &dyn ControlPlaneStore,
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
relative_path: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
let effective_settings =
|
||
effective_local_index_settings_for_root(store, workspace_id, root_path)?;
|
||
if !effective_settings.run_on_change {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index_for_path_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
&effective_settings,
|
||
relative_path,
|
||
)
|
||
.map(Some)
|
||
}
|
||
|
||
pub(crate) fn refresh_local_search_index_if_scheduled_due_with_store(
|
||
store: &dyn ControlPlaneStore,
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Option<Value>, WebError> {
|
||
let effective_settings =
|
||
effective_local_index_settings_for_root(store, workspace_id, root_path)?;
|
||
let built_at_ms = current_local_search_index_built_at(root_path)?.unwrap_or(0);
|
||
if !local_index_any_schedule_due_for_root(store, workspace_id, root_path, built_at_ms)? {
|
||
return Ok(None);
|
||
}
|
||
refresh_local_search_index_with_settings(root_path, root_uri, workspace_id, &effective_settings)
|
||
.map(Some)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn query_evidence_sqlite_results(
|
||
root_path: &Path,
|
||
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() {
|
||
return Ok(None);
|
||
}
|
||
let normalized_query = query.trim();
|
||
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,
|
||
resource_path.as_deref(),
|
||
limit,
|
||
)?,
|
||
};
|
||
Ok(Some(results))
|
||
}
|
||
|
||
pub(crate) fn read_evidence_sqlite_context(
|
||
root_path: &Path,
|
||
locator: &EvidenceLocator,
|
||
before_blocks: u32,
|
||
after_blocks: u32,
|
||
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||
let path = evidence_sqlite_path(root_path);
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
let connection = Connection::open(&path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
let mut statement = connection
|
||
.prepare(
|
||
"SELECT b.block_id, b.text, b.locator_json \
|
||
FROM evidence_block b \
|
||
JOIN evidence_resource r ON r.resource_id = b.resource_id \
|
||
WHERE r.owner_document_id = ?1 \
|
||
ORDER BY b.rowid",
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
let rows = statement
|
||
.query_map(params![locator.owner_document_id], |row| {
|
||
let block_id: String = row.get(0)?;
|
||
let text: String = row.get(1)?;
|
||
let locator_json: String = row.get(2)?;
|
||
let source =
|
||
serde_json::from_str::<EvidenceLocator>(&locator_json).map_err(|error| {
|
||
rusqlite::Error::FromSqlConversionFailure(
|
||
2,
|
||
rusqlite::types::Type::Text,
|
||
Box::new(error),
|
||
)
|
||
})?;
|
||
Ok((block_id, text, source))
|
||
})
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(sqlite_error)?;
|
||
if rows.is_empty() {
|
||
return Ok(Some(Vec::new()));
|
||
}
|
||
let Some(anchor_index) = rows
|
||
.iter()
|
||
.position(|(block_id, _, source)| evidence_locator_matches(block_id, source, locator))
|
||
else {
|
||
return Ok(Some(Vec::new()));
|
||
};
|
||
let start = anchor_index.saturating_sub(before_blocks as usize);
|
||
let end = (anchor_index + after_blocks as usize + 1).min(rows.len());
|
||
let results = rows[start..end]
|
||
.iter()
|
||
.map(|(block_id, text, source)| EvidenceSearchResult {
|
||
evidence_id: block_id.clone(),
|
||
quote: text.clone(),
|
||
score: if evidence_locator_matches(block_id, source, locator) {
|
||
1.0
|
||
} else {
|
||
0.8
|
||
},
|
||
source: source.clone(),
|
||
match_info: None,
|
||
citation_url: None,
|
||
citation_label: None,
|
||
citation_markdown: None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
Ok(Some(results))
|
||
}
|
||
|
||
pub(crate) fn query_evidence_graph_results(
|
||
root_path: &Path,
|
||
query: &str,
|
||
owner_document_id: Option<&str>,
|
||
limit: u32,
|
||
) -> Result<Option<Vec<EvidenceSearchResult>>, WebError> {
|
||
let path = evidence_sqlite_path(root_path);
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
let normalized_query = query.trim().to_ascii_lowercase();
|
||
if normalized_query.is_empty() {
|
||
return Ok(Some(Vec::new()));
|
||
}
|
||
let connection = Connection::open(&path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
let like_query = format!("%{}%", normalized_query.replace('%', "\\%"));
|
||
let mut statement = if owner_document_id.is_some() {
|
||
connection.prepare(
|
||
"SELECT e.edge_id, e.edge_type, e.from_id, e.to_id, e.source_block_id, b.text, b.locator_json \
|
||
FROM evidence_edge e \
|
||
JOIN evidence_block b ON b.block_id = e.source_block_id \
|
||
JOIN evidence_resource r ON r.resource_id = b.resource_id \
|
||
WHERE r.owner_document_id = ?2 \
|
||
AND (lower(e.edge_type) LIKE ?1 OR lower(e.from_id) LIKE ?1 OR lower(e.to_id) LIKE ?1) \
|
||
ORDER BY e.edge_type, e.from_id, e.to_id \
|
||
LIMIT ?3",
|
||
)
|
||
} else {
|
||
connection.prepare(
|
||
"SELECT e.edge_id, e.edge_type, e.from_id, e.to_id, e.source_block_id, b.text, b.locator_json \
|
||
FROM evidence_edge e \
|
||
JOIN evidence_block b ON b.block_id = e.source_block_id \
|
||
WHERE lower(e.edge_type) LIKE ?1 OR lower(e.from_id) LIKE ?1 OR lower(e.to_id) LIKE ?1 \
|
||
ORDER BY e.edge_type, e.from_id, e.to_id \
|
||
LIMIT ?2",
|
||
)
|
||
}
|
||
.map_err(sqlite_error)?;
|
||
let results = if let Some(owner_document_id) = owner_document_id {
|
||
statement
|
||
.query_map(
|
||
params![like_query, owner_document_id, limit],
|
||
graph_edge_row,
|
||
)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(sqlite_error)?
|
||
} else {
|
||
statement
|
||
.query_map(params![like_query, limit], graph_edge_row)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(sqlite_error)?
|
||
};
|
||
Ok(Some(results))
|
||
}
|
||
|
||
fn graph_edge_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<EvidenceSearchResult> {
|
||
let edge_id: String = row.get(0)?;
|
||
let edge_type: String = row.get(1)?;
|
||
let from_id: String = row.get(2)?;
|
||
let to_id: String = row.get(3)?;
|
||
let source_block_id: String = row.get(4)?;
|
||
let text: String = row.get(5)?;
|
||
let locator_json: String = row.get(6)?;
|
||
let source = serde_json::from_str::<EvidenceLocator>(&locator_json).map_err(|error| {
|
||
rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(error))
|
||
})?;
|
||
Ok(EvidenceSearchResult {
|
||
evidence_id: edge_id,
|
||
quote: format!("{edge_type}: {from_id} -> {to_id}\n{text}"),
|
||
score: 1.0,
|
||
source: EvidenceLocator {
|
||
block_id: source.block_id.or(Some(source_block_id)),
|
||
..source
|
||
},
|
||
match_info: None,
|
||
citation_url: None,
|
||
citation_label: None,
|
||
citation_markdown: None,
|
||
})
|
||
}
|
||
|
||
fn evidence_locator_matches(
|
||
block_id: &str,
|
||
source: &EvidenceLocator,
|
||
locator: &EvidenceLocator,
|
||
) -> bool {
|
||
locator.block_id.as_deref().is_some_and(|locator_block_id| {
|
||
locator_block_id == block_id || source.block_id.as_deref() == Some(locator_block_id)
|
||
}) || source.owner_document_id == locator.owner_document_id
|
||
&& source.resource_path == locator.resource_path
|
||
&& source.source_map_path == locator.source_map_path
|
||
}
|
||
|
||
fn query_evidence_sqlite_fts(
|
||
connection: &Connection,
|
||
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(
|
||
"SELECT b.block_id, b.text, b.locator_json, bm25(evidence_fts) AS rank \
|
||
FROM evidence_fts \
|
||
JOIN evidence_block b ON b.block_id = evidence_fts.block_id \
|
||
JOIN evidence_resource r ON r.resource_id = b.resource_id \
|
||
WHERE evidence_fts MATCH ?1",
|
||
);
|
||
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() || resource_path.is_some() {
|
||
" ORDER BY rank LIMIT ?3"
|
||
} else {
|
||
" ORDER BY rank LIMIT ?2"
|
||
});
|
||
let limit = i64::from(limit.max(1));
|
||
let mut statement = connection.prepare(&sql).map_err(sqlite_error)?;
|
||
let rows = if let Some(owner_document_id) = owner_document_id {
|
||
statement
|
||
.query_map(params![fts_query, owner_document_id, limit], |row| {
|
||
evidence_result_from_sqlite_row(row, display_query)
|
||
})
|
||
.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| {
|
||
evidence_result_from_sqlite_row(row, display_query)
|
||
})
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
};
|
||
rows.map_err(sqlite_error)
|
||
}
|
||
|
||
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(
|
||
"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 \
|
||
WHERE b.text LIKE ?1 ESCAPE '\\'",
|
||
);
|
||
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() || resource_path.is_some() {
|
||
" LIMIT ?3"
|
||
} else {
|
||
" LIMIT ?2"
|
||
});
|
||
let like_query = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
|
||
let limit = i64::from(limit.max(1));
|
||
let mut statement = connection.prepare(&sql).map_err(sqlite_error)?;
|
||
let rows = if let Some(owner_document_id) = owner_document_id {
|
||
statement
|
||
.query_map(params![like_query, owner_document_id, limit], |row| {
|
||
evidence_result_from_sqlite_row(row, query)
|
||
})
|
||
.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| {
|
||
evidence_result_from_sqlite_row(row, query)
|
||
})
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
};
|
||
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],
|
||
evidence_sqlite_row_parts,
|
||
)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>(),
|
||
(Some(scope_value), None) => statement
|
||
.query_map(params![scope_value, scan_limit], evidence_sqlite_row_parts)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>(),
|
||
(None, Some(first_char_like)) => statement
|
||
.query_map(
|
||
params![first_char_like, scan_limit],
|
||
evidence_sqlite_row_parts,
|
||
)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>(),
|
||
(None, None) => statement
|
||
.query_map(params![scan_limit], evidence_sqlite_row_parts)
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>(),
|
||
}
|
||
.map_err(sqlite_error)?;
|
||
let mut scored = rows
|
||
.into_iter()
|
||
.filter_map(|(block_id, text, source)| {
|
||
let text_match = score_evidence_text_match(&text, query)?;
|
||
Some(EvidenceSearchResult {
|
||
evidence_id: block_id,
|
||
quote: ocr_search_snippet_for_terms(&text, query, &text_match.matched_terms),
|
||
score: text_match.score,
|
||
source,
|
||
match_info: Some(text_match.into_match_info(None)),
|
||
citation_url: None,
|
||
citation_label: None,
|
||
citation_markdown: None,
|
||
})
|
||
})
|
||
.collect::<Vec<_>>();
|
||
scored.sort_by(|left, right| {
|
||
right
|
||
.score
|
||
.partial_cmp(&left.score)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
});
|
||
Ok(scored
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, mut result)| {
|
||
if let Some(match_info) = result.match_info.as_mut() {
|
||
match_info.rank = Some((index + 1) as u32);
|
||
}
|
||
result
|
||
})
|
||
.take(limit.max(1) as usize)
|
||
.collect())
|
||
}
|
||
|
||
fn evidence_sqlite_row_parts(
|
||
row: &rusqlite::Row<'_>,
|
||
) -> rusqlite::Result<(String, String, EvidenceLocator)> {
|
||
let block_id: String = row.get(0)?;
|
||
let text: String = row.get(1)?;
|
||
let locator_json: String = row.get(2)?;
|
||
let source = serde_json::from_str::<EvidenceLocator>(&locator_json).map_err(|error| {
|
||
rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(error))
|
||
})?;
|
||
Ok((block_id, text, source))
|
||
}
|
||
|
||
fn evidence_result_from_sqlite_row(
|
||
row: &rusqlite::Row<'_>,
|
||
query: &str,
|
||
) -> rusqlite::Result<EvidenceSearchResult> {
|
||
let block_id: String = row.get(0)?;
|
||
let text: String = row.get(1)?;
|
||
let locator_json: String = row.get(2)?;
|
||
let rank: f64 = row.get(3)?;
|
||
let source = serde_json::from_str::<EvidenceLocator>(&locator_json).map_err(|error| {
|
||
rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(error))
|
||
})?;
|
||
let text_match = score_evidence_text_match(&text, query);
|
||
Ok(EvidenceSearchResult {
|
||
evidence_id: block_id,
|
||
quote: text_match
|
||
.as_ref()
|
||
.map(|text_match| ocr_search_snippet_for_terms(&text, query, &text_match.matched_terms))
|
||
.unwrap_or_else(|| ocr_search_snippet(&text, query)),
|
||
score: if rank == 0.0 {
|
||
1.0
|
||
} else {
|
||
1.0 / (1.0 + rank.abs())
|
||
},
|
||
source,
|
||
match_info: text_match.map(|text_match| text_match.into_match_info(None)),
|
||
citation_url: None,
|
||
citation_label: None,
|
||
citation_markdown: None,
|
||
})
|
||
}
|
||
|
||
fn evidence_fts_phrase(query: &str) -> String {
|
||
format!("\"{}\"", query.replace('"', "\"\""))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub(crate) fn refresh_local_search_index(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Value, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
refresh_local_search_index_with_settings(root_path, root_uri, workspace_id, &settings)
|
||
}
|
||
|
||
pub(crate) fn refresh_local_search_index_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
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!({
|
||
"version": index.version,
|
||
"rootUri": index.root_uri,
|
||
"workspaceId": index.workspace_id,
|
||
"indexedPaths": index.indexed_paths,
|
||
"builtAt": index.built_at,
|
||
"documentCount": index.documents.len(),
|
||
"resourceCount": index.resources.len()
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
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 settings = read_local_index_settings_or_default(root_path)?;
|
||
refresh_local_search_index_for_path_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
&settings,
|
||
relative_path,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn refresh_local_search_index_for_path_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
settings: &LocalIndexSettings,
|
||
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
|
||
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths =>
|
||
{
|
||
index
|
||
}
|
||
Ok(_) | Err(_) => {
|
||
return refresh_local_search_index_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
settings,
|
||
);
|
||
}
|
||
};
|
||
|
||
index
|
||
.documents
|
||
.retain(|document| document.path != relative_path);
|
||
index
|
||
.resources
|
||
.retain(|resource| resource.path != relative_path);
|
||
let included = index_relative_path_is_included(&relative_path, &settings.include_paths);
|
||
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
|
||
index.built_at = now_ms();
|
||
write_local_search_index_json(root_path, &index)?;
|
||
let _ = included;
|
||
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
|
||
return Ok(json!({
|
||
"version": index.version,
|
||
"rootUri": index.root_uri,
|
||
"workspaceId": index.workspace_id,
|
||
"indexedPaths": index.indexed_paths,
|
||
"builtAt": index.built_at,
|
||
"documentCount": index.documents.len(),
|
||
"resourceCount": index.resources.len()
|
||
}));
|
||
}
|
||
if is_local_evidence_sidecar_relative_path(&relative_path) {
|
||
index.built_at = now_ms();
|
||
write_local_search_index_json(root_path, &index)?;
|
||
return Ok(json!({
|
||
"version": index.version,
|
||
"rootUri": index.root_uri,
|
||
"workspaceId": index.workspace_id,
|
||
"indexedPaths": index.indexed_paths,
|
||
"builtAt": index.built_at,
|
||
"documentCount": index.documents.len(),
|
||
"resourceCount": index.resources.len()
|
||
}));
|
||
}
|
||
let absolute_path = root_path.join(&relative_path);
|
||
if included
|
||
&& absolute_path.exists()
|
||
&& absolute_path.is_file()
|
||
&& is_markdown_path(&absolute_path)
|
||
{
|
||
index
|
||
.documents
|
||
.push(index_markdown_file(root_path, &absolute_path)?);
|
||
} else if included
|
||
&& 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_json(root_path, &index)?;
|
||
if included {
|
||
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
|
||
} else {
|
||
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
|
||
}
|
||
Ok(json!({
|
||
"version": index.version,
|
||
"rootUri": index.root_uri,
|
||
"workspaceId": index.workspace_id,
|
||
"indexedPaths": index.indexed_paths,
|
||
"builtAt": index.built_at,
|
||
"documentCount": index.documents.len(),
|
||
"resourceCount": index.resources.len()
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn query_local_backlinks(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
document_id: &str,
|
||
) -> Result<Value, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
query_local_backlinks_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
&settings,
|
||
&settings,
|
||
document_id,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn query_local_backlinks_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
cache_settings: &LocalIndexSettings,
|
||
result_settings: &LocalIndexSettings,
|
||
document_id: &str,
|
||
) -> Result<Value, WebError> {
|
||
let index = load_or_rebuild_local_search_index_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
cache_settings,
|
||
)?;
|
||
let Some(target) = index.documents.iter().find(|document| {
|
||
document.document_id == document_id
|
||
&& index_relative_path_is_included(&document.path, &result_settings.include_paths)
|
||
}) 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| {
|
||
index_relative_path_is_included(&document.path, &result_settings.include_paths)
|
||
})
|
||
.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
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
pub(crate) fn query_local_tags(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<Value, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
query_local_tags_with_settings(root_path, root_uri, workspace_id, &settings, &settings)
|
||
}
|
||
|
||
pub(crate) fn query_local_tags_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
cache_settings: &LocalIndexSettings,
|
||
result_settings: &LocalIndexSettings,
|
||
) -> Result<Value, WebError> {
|
||
let index = load_or_rebuild_local_search_index_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
cache_settings,
|
||
)?;
|
||
let mut tags = std::collections::BTreeMap::<String, Vec<&LocalSearchDocument>>::new();
|
||
for document in &index.documents {
|
||
if !index_relative_path_is_included(&document.path, &result_settings.include_paths) {
|
||
continue;
|
||
}
|
||
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<_>>()
|
||
}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
fn rebuild_local_search_index(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<LocalSearchIndex, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, &settings)
|
||
}
|
||
|
||
fn rebuild_local_search_index_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
settings: &LocalIndexSettings,
|
||
) -> Result<LocalSearchIndex, WebError> {
|
||
let mut documents = Vec::new();
|
||
let mut resources = Vec::new();
|
||
for include_path in &settings.include_paths {
|
||
let base_path = if include_path == "." {
|
||
root_path.to_path_buf()
|
||
} else {
|
||
root_path.join(include_path)
|
||
};
|
||
if base_path.exists() {
|
||
collect_markdown_documents(root_path, &base_path, &mut documents, &mut resources)?;
|
||
}
|
||
}
|
||
documents.sort_by(|left, right| left.path.cmp(&right.path));
|
||
documents.dedup_by(|left, right| left.path == right.path);
|
||
resources.sort_by(|left, right| left.path.cmp(&right.path));
|
||
resources.dedup_by(|left, right| left.path == 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(),
|
||
indexed_paths: settings.include_paths.clone(),
|
||
documents,
|
||
resources,
|
||
};
|
||
write_local_search_index(root_path, &index)?;
|
||
Ok(index)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[allow(dead_code)]
|
||
fn load_or_rebuild_local_search_index(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
) -> Result<LocalSearchIndex, WebError> {
|
||
let settings = read_local_index_settings_or_default(root_path)?;
|
||
load_or_rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, &settings)
|
||
}
|
||
|
||
fn load_or_rebuild_local_search_index_with_settings(
|
||
root_path: &Path,
|
||
root_uri: &str,
|
||
workspace_id: &str,
|
||
settings: &LocalIndexSettings,
|
||
) -> 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
|
||
&& normalized_indexed_paths(&index.indexed_paths) == settings.include_paths =>
|
||
{
|
||
if local_index_schedule_is_due(&settings, index.built_at) {
|
||
return rebuild_local_search_index_with_settings(
|
||
root_path,
|
||
root_uri,
|
||
workspace_id,
|
||
settings,
|
||
);
|
||
}
|
||
ensure_evidence_sqlite_index(root_path, &index)?;
|
||
Ok(index)
|
||
}
|
||
Ok(_) | Err(_) => {
|
||
rebuild_local_search_index_with_settings(root_path, root_uri, workspace_id, settings)
|
||
}
|
||
}
|
||
}
|
||
|
||
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()),
|
||
)
|
||
})
|
||
}
|
||
|
||
pub(crate) fn current_local_search_index_built_at(
|
||
root_path: &Path,
|
||
) -> Result<Option<u128>, WebError> {
|
||
Ok(read_local_search_index(root_path)?.map(|index| index.built_at))
|
||
}
|
||
|
||
fn default_indexed_paths() -> Vec<String> {
|
||
vec![".".to_string()]
|
||
}
|
||
|
||
fn local_index_settings_path(root_path: &Path) -> PathBuf {
|
||
root_path
|
||
.join(".mnote")
|
||
.join("index")
|
||
.join("local-index-settings.json")
|
||
}
|
||
|
||
pub(crate) fn read_local_index_settings_or_default(
|
||
root_path: &Path,
|
||
) -> Result<LocalIndexSettings, WebError> {
|
||
let path = local_index_settings_path(root_path);
|
||
if !path.exists() {
|
||
return Ok(default_local_index_settings());
|
||
}
|
||
let content = fs::read_to_string(&path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"local_index_settings_read_failed",
|
||
format!("无法读取本地索引设置 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
parse_local_index_settings_value(root_path, &content).map_err(|error| {
|
||
if error.code() == "local_index_settings_invalid" {
|
||
WebError::bad_request_code(
|
||
"local_index_settings_invalid",
|
||
format!(
|
||
"本地索引设置格式非法 {}: {}",
|
||
path.display(),
|
||
error.message()
|
||
),
|
||
)
|
||
} else {
|
||
error
|
||
}
|
||
})
|
||
}
|
||
|
||
fn write_local_index_settings_cache(
|
||
root_path: &Path,
|
||
settings: &LocalIndexSettings,
|
||
) -> Result<(), WebError> {
|
||
let path = local_index_settings_path(root_path);
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"local_index_settings_create_failed",
|
||
format!("无法创建本地索引设置目录 {}: {error}", parent.display()),
|
||
)
|
||
})?;
|
||
}
|
||
let payload = serde_json::to_string_pretty(settings)
|
||
.map_err(|error| WebError::internal(format!("本地索引设置序列化失败: {error}")))?;
|
||
fs::write(&path, format!("{payload}\n")).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"local_index_settings_write_failed",
|
||
format!("无法写入本地索引设置 {}: {error}", path.display()),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn parse_local_index_settings_value(
|
||
root_path: &Path,
|
||
value_json: &str,
|
||
) -> Result<LocalIndexSettings, WebError> {
|
||
let raw = serde_json::from_str::<LocalIndexSettings>(value_json).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"local_index_settings_invalid",
|
||
format!("本地索引设置 JSON 非法: {error}"),
|
||
)
|
||
})?;
|
||
Ok(LocalIndexSettings {
|
||
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||
include_paths: normalize_index_include_paths(root_path, &raw.include_paths)?,
|
||
schedule_mode: normalize_index_schedule_mode(&raw.schedule_mode)?,
|
||
schedule_time: normalize_index_schedule_time(&raw.schedule_time)?,
|
||
schedule_date: normalize_index_schedule_date(raw.schedule_date.as_deref())?,
|
||
run_on_change: raw.run_on_change,
|
||
updated_at: raw.updated_at,
|
||
})
|
||
}
|
||
|
||
fn default_local_index_settings() -> LocalIndexSettings {
|
||
LocalIndexSettings {
|
||
schema: LOCAL_INDEX_SETTINGS_SCHEMA.to_string(),
|
||
include_paths: Vec::new(),
|
||
schedule_mode: default_index_schedule_mode(),
|
||
schedule_time: default_index_schedule_time(),
|
||
schedule_date: None,
|
||
run_on_change: default_index_run_on_change(),
|
||
updated_at: 0,
|
||
}
|
||
}
|
||
|
||
fn default_index_schedule_mode() -> String {
|
||
DEFAULT_INDEX_SCHEDULE_MODE.to_string()
|
||
}
|
||
|
||
fn default_index_schedule_time() -> String {
|
||
DEFAULT_INDEX_SCHEDULE_TIME.to_string()
|
||
}
|
||
|
||
fn default_index_run_on_change() -> bool {
|
||
false
|
||
}
|
||
|
||
fn normalize_index_schedule_mode(value: &str) -> Result<String, WebError> {
|
||
let mode = value.trim();
|
||
if mode.is_empty() {
|
||
return Ok(default_index_schedule_mode());
|
||
}
|
||
match mode {
|
||
"daily" | "once" | "manual" => Ok(mode.to_string()),
|
||
_ => Err(WebError::bad_request_code(
|
||
"local_index_schedule_mode_invalid",
|
||
"本地索引调度模式只能是 daily、once 或 manual",
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn normalize_index_schedule_time(value: &str) -> Result<String, WebError> {
|
||
let value = value.trim();
|
||
let (hour, minute) = parse_hh_mm(value)?;
|
||
Ok(format!("{hour:02}:{minute:02}"))
|
||
}
|
||
|
||
fn normalize_index_schedule_date(value: Option<&str>) -> Result<Option<String>, WebError> {
|
||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||
return Ok(None);
|
||
};
|
||
let _ = parse_yyyy_mm_dd(value)?;
|
||
Ok(Some(value.to_string()))
|
||
}
|
||
|
||
fn local_index_schedule_is_due(settings: &LocalIndexSettings, built_at_ms: u128) -> bool {
|
||
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
|
||
let Ok((hour, minute)) = parse_hh_mm(&settings.schedule_time) else {
|
||
return false;
|
||
};
|
||
let Ok(schedule_time) = Time::from_hms(hour, minute, 0) else {
|
||
return false;
|
||
};
|
||
let target = match settings.schedule_mode.as_str() {
|
||
"manual" => return false,
|
||
"once" => {
|
||
let Some(date) = settings
|
||
.schedule_date
|
||
.as_deref()
|
||
.and_then(|value| parse_yyyy_mm_dd(value).ok())
|
||
else {
|
||
return false;
|
||
};
|
||
date.with_time(schedule_time).assume_offset(now.offset())
|
||
}
|
||
_ => now
|
||
.date()
|
||
.with_time(schedule_time)
|
||
.assume_offset(now.offset()),
|
||
};
|
||
if now < target {
|
||
return false;
|
||
}
|
||
let built_at = unix_ms_to_local_datetime(built_at_ms);
|
||
built_at.map(|value| value < target).unwrap_or(true)
|
||
}
|
||
|
||
fn unix_ms_to_local_datetime(value: u128) -> Option<OffsetDateTime> {
|
||
let seconds = i64::try_from(value / 1000).ok()?;
|
||
let nanos = u32::try_from((value % 1000) * 1_000_000).ok()?;
|
||
OffsetDateTime::from_unix_timestamp(seconds)
|
||
.ok()
|
||
.map(|datetime| datetime + Duration::nanoseconds(nanos as i64))
|
||
.map(|datetime| {
|
||
datetime.to_offset(
|
||
OffsetDateTime::now_local()
|
||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||
.offset(),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn parse_hh_mm(value: &str) -> Result<(u8, u8), WebError> {
|
||
let parts = value.trim().split(':').collect::<Vec<_>>();
|
||
if parts.len() != 2 {
|
||
return Err(WebError::bad_request_code(
|
||
"local_index_schedule_time_invalid",
|
||
"本地索引时间必须是 HH:MM",
|
||
));
|
||
}
|
||
let hour = parts[0].parse::<u8>().map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_time_invalid",
|
||
"本地索引时间必须是 HH:MM",
|
||
)
|
||
})?;
|
||
let minute = parts[1].parse::<u8>().map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_time_invalid",
|
||
"本地索引时间必须是 HH:MM",
|
||
)
|
||
})?;
|
||
if hour > 23 || minute > 59 {
|
||
return Err(WebError::bad_request_code(
|
||
"local_index_schedule_time_invalid",
|
||
"本地索引时间必须是 00:00 到 23:59",
|
||
));
|
||
}
|
||
Ok((hour, minute))
|
||
}
|
||
|
||
fn parse_yyyy_mm_dd(value: &str) -> Result<Date, WebError> {
|
||
let parts = value.trim().split('-').collect::<Vec<_>>();
|
||
if parts.len() != 3 {
|
||
return Err(WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
));
|
||
}
|
||
let year = parts[0].parse::<i32>().map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
)
|
||
})?;
|
||
let month = parts[1].parse::<u8>().map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
)
|
||
})?;
|
||
let day = parts[2].parse::<u8>().map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
)
|
||
})?;
|
||
let month = Month::try_from(month).map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
)
|
||
})?;
|
||
Date::from_calendar_date(year, month, day).map_err(|_| {
|
||
WebError::bad_request_code(
|
||
"local_index_schedule_date_invalid",
|
||
"本地索引日期必须是 YYYY-MM-DD",
|
||
)
|
||
})
|
||
}
|
||
|
||
fn normalize_index_include_paths(
|
||
root_path: &Path,
|
||
include_paths: &[String],
|
||
) -> Result<Vec<String>, WebError> {
|
||
let mut output = Vec::new();
|
||
let root_canonical = root_path
|
||
.canonicalize()
|
||
.unwrap_or_else(|_| root_path.to_path_buf());
|
||
for value in include_paths {
|
||
let trimmed = value.trim();
|
||
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",
|
||
"本地索引目录不能指向 .mnote 内部缓存",
|
||
));
|
||
}
|
||
let absolute = if normalized == "." {
|
||
root_path.to_path_buf()
|
||
} else {
|
||
root_path.join(&normalized)
|
||
};
|
||
if let Ok(canonical) = absolute.canonicalize() {
|
||
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",
|
||
"本地索引范围必须是目录",
|
||
));
|
||
}
|
||
}
|
||
if output.iter().any(|existing| existing == &normalized) {
|
||
continue;
|
||
}
|
||
output.push(normalized);
|
||
}
|
||
Ok(normalized_indexed_paths(&output))
|
||
}
|
||
|
||
fn normalized_indexed_paths(paths: &[String]) -> Vec<String> {
|
||
let mut values = paths
|
||
.iter()
|
||
.map(|value| value.trim().replace('\\', "/"))
|
||
.filter(|value| !value.is_empty())
|
||
.map(|value| {
|
||
if value == "/" || value == "./" {
|
||
".".to_string()
|
||
} else {
|
||
value
|
||
.trim_start_matches("./")
|
||
.trim_end_matches('/')
|
||
.to_string()
|
||
}
|
||
})
|
||
.map(|value| {
|
||
if value.is_empty() {
|
||
".".to_string()
|
||
} else {
|
||
value
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
values.sort();
|
||
values.dedup();
|
||
values
|
||
}
|
||
|
||
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 index_relative_path_is_included(relative_path: &str, include_paths: &[String]) -> bool {
|
||
let relative_path = relative_path.trim().replace('\\', "/");
|
||
include_paths.iter().any(|include| {
|
||
include == "."
|
||
|| relative_path == *include
|
||
|| relative_path
|
||
.strip_prefix(include)
|
||
.map(|rest| rest.starts_with('/'))
|
||
.unwrap_or(false)
|
||
})
|
||
}
|
||
|
||
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;
|
||
}
|
||
let relative_path = path
|
||
.strip_prefix(root_path)
|
||
.unwrap_or(&path)
|
||
.to_string_lossy()
|
||
.replace('\\', "/");
|
||
if is_local_evidence_sidecar_relative_path(&relative_path) {
|
||
continue;
|
||
}
|
||
if is_markdown_path(&path) {
|
||
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> {
|
||
write_local_search_index_json(root_path, index)?;
|
||
write_evidence_sqlite_index(root_path, index)
|
||
}
|
||
|
||
fn write_local_search_index_json(
|
||
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 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,
|
||
) -> Result<(), WebError> {
|
||
let path = evidence_sqlite_path(root_path);
|
||
if path.exists() {
|
||
return Ok(());
|
||
}
|
||
write_evidence_sqlite_index(root_path, index)
|
||
}
|
||
|
||
fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
|
||
let Some(parent) = evidence_sqlite_path(root_path)
|
||
.parent()
|
||
.map(Path::to_path_buf)
|
||
else {
|
||
return Ok(());
|
||
};
|
||
fs::create_dir_all(&parent).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_create_failed",
|
||
format!("无法创建 evidence 索引目录 {}: {error}", parent.display()),
|
||
)
|
||
})?;
|
||
let path = evidence_sqlite_path(root_path);
|
||
let mut connection = Connection::open(&path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
let tx = connection.transaction().map_err(sqlite_error)?;
|
||
create_evidence_schema(&tx)?;
|
||
tx.execute("DELETE FROM evidence_fts", [])
|
||
.map_err(sqlite_error)?;
|
||
for table in [
|
||
"evidence_edge",
|
||
"evidence_section",
|
||
"evidence_block",
|
||
"evidence_resource",
|
||
] {
|
||
tx.execute(&format!("DELETE FROM {table}"), [])
|
||
.map_err(sqlite_error)?;
|
||
}
|
||
for document in &index.documents {
|
||
insert_document_evidence(&tx, index, document)?;
|
||
}
|
||
for resource in &index.resources {
|
||
insert_resource_evidence(&tx, root_path, index, resource)?;
|
||
}
|
||
for document in &index.documents {
|
||
insert_document_graph_edges(&tx, document)?;
|
||
}
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('schema_version', ?1)",
|
||
params![EVIDENCE_SQLITE_SCHEMA_VERSION.to_string()],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('workspace_id', ?1)",
|
||
params![index.workspace_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
tx.commit().map_err(sqlite_error)
|
||
}
|
||
|
||
fn create_evidence_schema(connection: &Connection) -> Result<(), WebError> {
|
||
connection
|
||
.execute_batch(
|
||
r#"
|
||
CREATE TABLE IF NOT EXISTS evidence_meta(
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS evidence_resource(
|
||
resource_id TEXT PRIMARY KEY,
|
||
owner_document_id TEXT NOT NULL,
|
||
owner_document_path TEXT NOT NULL,
|
||
source_root_relative_path TEXT NOT NULL,
|
||
provider TEXT NOT NULL,
|
||
source_hash TEXT NOT NULL,
|
||
artifact_root_relative_path TEXT NOT NULL,
|
||
source_map_root_relative_path TEXT NOT NULL,
|
||
updated_at_ms INTEGER NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS evidence_block(
|
||
block_id TEXT PRIMARY KEY,
|
||
resource_id TEXT NOT NULL,
|
||
text TEXT NOT NULL,
|
||
section_path_json TEXT NOT NULL,
|
||
page_start INTEGER,
|
||
page_end INTEGER,
|
||
bbox_json TEXT,
|
||
char_range_json TEXT,
|
||
line_range_json TEXT,
|
||
locator_json TEXT NOT NULL
|
||
);
|
||
CREATE VIRTUAL TABLE IF NOT EXISTS evidence_fts USING fts5(
|
||
block_id UNINDEXED,
|
||
text
|
||
);
|
||
CREATE TABLE IF NOT EXISTS evidence_section(
|
||
section_id TEXT PRIMARY KEY,
|
||
resource_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
path_json TEXT NOT NULL,
|
||
summary TEXT,
|
||
page_start INTEGER,
|
||
page_end INTEGER,
|
||
parent_section_id TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS evidence_edge(
|
||
edge_id TEXT PRIMARY KEY,
|
||
from_id TEXT NOT NULL,
|
||
to_id TEXT NOT NULL,
|
||
edge_type TEXT NOT NULL,
|
||
source_block_id TEXT,
|
||
confidence REAL NOT NULL,
|
||
created_by TEXT NOT NULL
|
||
);
|
||
"#,
|
||
)
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn refresh_evidence_sqlite_index_for_path(
|
||
root_path: &Path,
|
||
index: &LocalSearchIndex,
|
||
relative_path: &str,
|
||
) -> Result<(), WebError> {
|
||
if let Some(parent) = evidence_sqlite_path(root_path)
|
||
.parent()
|
||
.map(Path::to_path_buf)
|
||
{
|
||
fs::create_dir_all(&parent).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_create_failed",
|
||
format!("无法创建 evidence 索引目录 {}: {error}", parent.display()),
|
||
)
|
||
})?;
|
||
}
|
||
let mut connection = Connection::open(evidence_sqlite_path(root_path)).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引: {error}"),
|
||
)
|
||
})?;
|
||
let tx = connection.transaction().map_err(sqlite_error)?;
|
||
create_evidence_schema(&tx)?;
|
||
let mut affected_resource_ids = affected_evidence_resource_ids(index, relative_path);
|
||
affected_resource_ids.extend(existing_evidence_resource_ids_for_path(&tx, relative_path)?);
|
||
affected_resource_ids.sort();
|
||
affected_resource_ids.dedup();
|
||
for resource_id in &affected_resource_ids {
|
||
delete_evidence_resource_projection(&tx, resource_id)?;
|
||
}
|
||
if let Some(document) = index
|
||
.documents
|
||
.iter()
|
||
.find(|document| document.path == relative_path)
|
||
{
|
||
insert_document_evidence(&tx, index, document)?;
|
||
insert_document_graph_edges(&tx, document)?;
|
||
}
|
||
if let Some(resource) = index
|
||
.resources
|
||
.iter()
|
||
.find(|resource| resource.path == relative_path)
|
||
{
|
||
insert_resource_evidence(&tx, root_path, index, resource)?;
|
||
}
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('schema_version', ?1)",
|
||
params![EVIDENCE_SQLITE_SCHEMA_VERSION.to_string()],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
tx.execute(
|
||
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('workspace_id', ?1)",
|
||
params![index.workspace_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
tx.commit().map_err(sqlite_error)
|
||
}
|
||
|
||
fn remove_evidence_sqlite_index_for_path(
|
||
root_path: &Path,
|
||
index: &LocalSearchIndex,
|
||
relative_path: &str,
|
||
) -> Result<(), WebError> {
|
||
let path = evidence_sqlite_path(root_path);
|
||
if !path.exists() {
|
||
return Ok(());
|
||
}
|
||
let mut connection = Connection::open(&path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
let tx = connection.transaction().map_err(sqlite_error)?;
|
||
create_evidence_schema(&tx)?;
|
||
let mut affected_resource_ids = affected_evidence_resource_ids(index, relative_path);
|
||
affected_resource_ids.extend(existing_evidence_resource_ids_for_path(&tx, relative_path)?);
|
||
affected_resource_ids.sort();
|
||
affected_resource_ids.dedup();
|
||
for resource_id in &affected_resource_ids {
|
||
delete_evidence_resource_projection(&tx, resource_id)?;
|
||
}
|
||
tx.commit().map_err(sqlite_error)
|
||
}
|
||
|
||
fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str) -> Vec<String> {
|
||
let mut ids = Vec::new();
|
||
let document_id = format!("local-md:{}", encode_local_id_segment(relative_path));
|
||
ids.push(document_id.clone());
|
||
ids.push(format!(
|
||
"local-resource:{}",
|
||
relative_path.replace('/', "~2F")
|
||
));
|
||
ids.extend(
|
||
index
|
||
.documents
|
||
.iter()
|
||
.filter(|document| document.path == relative_path)
|
||
.map(|document| document.document_id.clone()),
|
||
);
|
||
ids.extend(
|
||
index
|
||
.resources
|
||
.iter()
|
||
.filter(|resource| resource.path == relative_path)
|
||
.map(|resource| resource.resource_id.clone()),
|
||
);
|
||
if let Some(stripped) = relative_path.strip_suffix(".ocr.md") {
|
||
ids.push(format!("{document_id}#ocr:{stripped}"));
|
||
}
|
||
ids
|
||
}
|
||
|
||
fn existing_evidence_resource_ids_for_path(
|
||
connection: &Connection,
|
||
relative_path: &str,
|
||
) -> Result<Vec<String>, WebError> {
|
||
let mut statement = connection
|
||
.prepare(
|
||
"SELECT resource_id FROM evidence_resource \
|
||
WHERE source_root_relative_path = ?1 \
|
||
OR artifact_root_relative_path = ?1 \
|
||
OR source_map_root_relative_path = ?1",
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
let resource_ids = statement
|
||
.query_map(params![relative_path], |row| row.get::<_, String>(0))
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(sqlite_error)?;
|
||
Ok(resource_ids)
|
||
}
|
||
|
||
fn delete_evidence_resource_projection(
|
||
connection: &Connection,
|
||
resource_id: &str,
|
||
) -> Result<(), WebError> {
|
||
let mut statement = connection
|
||
.prepare("SELECT block_id FROM evidence_block WHERE resource_id = ?1")
|
||
.map_err(sqlite_error)?;
|
||
let block_ids = statement
|
||
.query_map(params![resource_id], |row| row.get::<_, String>(0))
|
||
.map_err(sqlite_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(sqlite_error)?;
|
||
for block_id in &block_ids {
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_fts WHERE block_id = ?1",
|
||
params![block_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_edge WHERE source_block_id = ?1",
|
||
params![block_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
}
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_edge WHERE from_id = ?1",
|
||
params![resource_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_section WHERE resource_id = ?1",
|
||
params![resource_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_block WHERE resource_id = ?1",
|
||
params![resource_id],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
connection
|
||
.execute(
|
||
"DELETE FROM evidence_resource WHERE resource_id = ?1",
|
||
params![resource_id],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn insert_document_evidence(
|
||
connection: &Connection,
|
||
index: &LocalSearchIndex,
|
||
document: &LocalSearchDocument,
|
||
) -> Result<(), WebError> {
|
||
let resource_id = document.document_id.as_str();
|
||
let artifact = markdown_parsed_artifact(document);
|
||
insert_evidence_resource_from_artifact(connection, resource_id, &artifact)?;
|
||
for section in markdown_evidence_sections(document) {
|
||
insert_markdown_evidence_section(connection, resource_id, §ion)?;
|
||
}
|
||
let blocks = markdown_evidence_blocks(document);
|
||
if blocks.is_empty() {
|
||
let block_id = format!("{resource_id}#line1");
|
||
let line_range = json!({"start": 1, "end": 1});
|
||
let locator = markdown_locator(
|
||
index,
|
||
&document.document_id,
|
||
&document.path,
|
||
&block_id,
|
||
Vec::new(),
|
||
Some(line_range.clone()),
|
||
);
|
||
return insert_evidence_block_with_metadata(
|
||
connection,
|
||
&block_id,
|
||
resource_id,
|
||
&document.raw_text,
|
||
Vec::new(),
|
||
Some(line_range),
|
||
locator,
|
||
);
|
||
}
|
||
for block in blocks {
|
||
let locator = markdown_locator(
|
||
index,
|
||
&document.document_id,
|
||
&document.path,
|
||
&block.block_id,
|
||
block.section_path.clone(),
|
||
Some(block.line_range.clone()),
|
||
);
|
||
insert_evidence_block_with_metadata(
|
||
connection,
|
||
&block.block_id,
|
||
resource_id,
|
||
&block.text,
|
||
block.section_path,
|
||
Some(block.line_range),
|
||
locator,
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArtifact {
|
||
ParsedResourceArtifact {
|
||
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
|
||
provider: "markdown".into(),
|
||
model_version: None,
|
||
owner_document_id: document.document_id.clone(),
|
||
owner_document_path: document.path.clone(),
|
||
source_root_relative_path: document.path.clone(),
|
||
source_hash: format!("updated:{}", document.updated_at),
|
||
artifact_root_relative_path: document.path.clone(),
|
||
source_map_root_relative_path: format!("{}.source-map.json", document.path),
|
||
updated_at_ms: document.updated_at as u64,
|
||
}
|
||
}
|
||
|
||
fn insert_evidence_resource_from_artifact(
|
||
connection: &Connection,
|
||
resource_id: &str,
|
||
artifact: &ParsedResourceArtifact,
|
||
) -> Result<(), WebError> {
|
||
insert_evidence_resource(
|
||
connection,
|
||
resource_id,
|
||
&artifact.owner_document_id,
|
||
&artifact.owner_document_path,
|
||
&artifact.source_root_relative_path,
|
||
&artifact.provider,
|
||
&artifact.source_hash,
|
||
&artifact.artifact_root_relative_path,
|
||
&artifact.source_map_root_relative_path,
|
||
artifact.updated_at_ms as u128,
|
||
)
|
||
}
|
||
|
||
fn insert_resource_evidence(
|
||
connection: &Connection,
|
||
root_path: &Path,
|
||
index: &LocalSearchIndex,
|
||
resource: &LocalSearchResource,
|
||
) -> Result<(), WebError> {
|
||
insert_evidence_resource(
|
||
connection,
|
||
&resource.resource_id,
|
||
&resource.resource_id,
|
||
&resource.path,
|
||
&resource.path,
|
||
"resource",
|
||
&format!("updated:{}", resource.updated_at),
|
||
&resource.path,
|
||
"",
|
||
resource.updated_at,
|
||
)?;
|
||
let locator = json!({
|
||
"schema": "mnote.evidence_locator.v1",
|
||
"rootUri": index.root_uri,
|
||
"ownerDocumentId": resource.resource_id,
|
||
"ownerDocumentPath": resource.path,
|
||
"resourcePath": resource.path,
|
||
"resourceKind": evidence_resource_kind_for_type(&resource.resource_type),
|
||
"openAction": {
|
||
"actionType": "mnote.open_resource_locator",
|
||
"url": format!("/?treeView=filetree&sourceKind=local_folder&rootUri={}", encode_query_component(&index.root_uri)),
|
||
"params": {"resourcePath": resource.path}
|
||
}
|
||
});
|
||
insert_evidence_block(
|
||
connection,
|
||
&format!("{}#resource", resource.resource_id),
|
||
&resource.resource_id,
|
||
&format!("{}\n{}", resource.title, resource.path),
|
||
locator,
|
||
)?;
|
||
if let Some(output) = parse_resource_evidence_artifact(root_path, index, resource)? {
|
||
insert_source_map_artifact_evidence(
|
||
connection,
|
||
index,
|
||
&parsed_resource_id(resource),
|
||
&output.artifact,
|
||
&output.source_map,
|
||
&output.markdown,
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn parse_resource_evidence_artifact(
|
||
_root_path: &Path,
|
||
_index: &LocalSearchIndex,
|
||
_resource: &LocalSearchResource,
|
||
) -> Result<Option<crate::evidence_parse::ParseProviderOutput>, WebError> {
|
||
Ok(None)
|
||
}
|
||
|
||
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
|
||
format!("{}#parse", resource.resource_id)
|
||
}
|
||
|
||
fn insert_source_map_artifact_evidence(
|
||
connection: &Connection,
|
||
index: &LocalSearchIndex,
|
||
resource_id: &str,
|
||
artifact: &ParsedResourceArtifact,
|
||
source_map: &ResourceSourceMap,
|
||
fallback_text: &str,
|
||
) -> Result<(), WebError> {
|
||
insert_evidence_resource_from_artifact(connection, resource_id, artifact)?;
|
||
for section in &source_map.sections {
|
||
insert_source_map_evidence_section(connection, resource_id, section)?;
|
||
}
|
||
let mut inserted = 0_usize;
|
||
for page in &source_map.pages {
|
||
for block in &page.blocks {
|
||
if block.text.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let block_id = format!("{resource_id}#{}", block.id);
|
||
let section_path = source_map_section_path_for_block(source_map, &block.id);
|
||
let locator = source_map_block_locator(
|
||
index,
|
||
artifact,
|
||
&block.id,
|
||
Some(page.page),
|
||
block,
|
||
section_path.clone(),
|
||
);
|
||
insert_evidence_block_with_metadata(
|
||
connection,
|
||
&block_id,
|
||
resource_id,
|
||
&block.text,
|
||
section_path,
|
||
None,
|
||
locator,
|
||
)?;
|
||
inserted += 1;
|
||
}
|
||
}
|
||
if inserted == 0 {
|
||
let block_id = format!("{resource_id}#artifact");
|
||
let fallback_block = empty_source_map_block(fallback_text);
|
||
let locator = source_map_block_locator(
|
||
index,
|
||
artifact,
|
||
&fallback_block.id,
|
||
None,
|
||
&fallback_block,
|
||
Vec::new(),
|
||
);
|
||
insert_evidence_block_with_metadata(
|
||
connection,
|
||
&block_id,
|
||
resource_id,
|
||
fallback_text,
|
||
Vec::new(),
|
||
None,
|
||
locator,
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn insert_source_map_evidence_section(
|
||
connection: &Connection,
|
||
resource_id: &str,
|
||
section: &core_protocol::SourceMapSection,
|
||
) -> Result<(), WebError> {
|
||
let path_json = serde_json::to_string(§ion.path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_section_serialize_failed",
|
||
format!("source-map section path 序列化失败: {error}"),
|
||
)
|
||
})?;
|
||
connection
|
||
.execute(
|
||
"INSERT OR REPLACE INTO evidence_section(section_id, resource_id, title, path_json, summary, page_start, page_end, parent_section_id) VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?6, NULL)",
|
||
params![
|
||
format!("{resource_id}#{}", section.id),
|
||
resource_id,
|
||
section.title,
|
||
path_json,
|
||
section.page_start,
|
||
section.page_end,
|
||
],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn source_map_section_path_for_block(
|
||
source_map: &ResourceSourceMap,
|
||
block_id: &str,
|
||
) -> Vec<String> {
|
||
source_map
|
||
.sections
|
||
.iter()
|
||
.find(|section| section.block_ids.iter().any(|id| id == block_id))
|
||
.map(|section| section.path.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn source_map_block_locator(
|
||
index: &LocalSearchIndex,
|
||
artifact: &ParsedResourceArtifact,
|
||
source_map_block_id: &str,
|
||
page: Option<u32>,
|
||
block: &SourceMapBlock,
|
||
section_path: Vec<String>,
|
||
) -> Value {
|
||
let mut params = json!({
|
||
"resourcePath": artifact.source_root_relative_path,
|
||
"sourceMapPath": artifact.source_map_root_relative_path,
|
||
"blockId": source_map_block_id,
|
||
});
|
||
if let Some(params_object) = params.as_object_mut() {
|
||
if let Some(page) = page {
|
||
params_object.insert("page".into(), json!(page));
|
||
}
|
||
if let Some(bbox) = &block.bbox {
|
||
params_object.insert("bbox".into(), json!(bbox));
|
||
}
|
||
if let Some(char_range) = &block.char_range {
|
||
params_object.insert("charRange".into(), json!(char_range));
|
||
}
|
||
}
|
||
json!({
|
||
"schema": "mnote.evidence_locator.v1",
|
||
"rootUri": index.root_uri,
|
||
"ownerDocumentId": artifact.owner_document_id,
|
||
"ownerDocumentPath": artifact.owner_document_path,
|
||
"resourcePath": artifact.source_root_relative_path,
|
||
"resourceKind": evidence_resource_kind_for_path(&artifact.source_root_relative_path),
|
||
"page": page,
|
||
"bbox": block.bbox,
|
||
"sectionPath": section_path,
|
||
"charRange": block.char_range,
|
||
"blockId": source_map_block_id,
|
||
"sourceMapPath": artifact.source_map_root_relative_path,
|
||
"openAction": {
|
||
"actionType": "mnote.open_resource_locator",
|
||
"url": format!("/documents/{}?sourceKind=local_folder&rootUri={}", artifact.owner_document_id, encode_query_component(&index.root_uri)),
|
||
"params": params
|
||
}
|
||
})
|
||
}
|
||
|
||
fn empty_source_map_block(text: &str) -> SourceMapBlock {
|
||
SourceMapBlock {
|
||
id: "artifact".into(),
|
||
block_type: core_protocol::SourceMapBlockKind::Text,
|
||
text: text.to_string(),
|
||
bbox: None,
|
||
char_range: None,
|
||
}
|
||
}
|
||
|
||
fn insert_evidence_resource(
|
||
connection: &Connection,
|
||
resource_id: &str,
|
||
owner_document_id: &str,
|
||
owner_document_path: &str,
|
||
source_root_relative_path: &str,
|
||
provider: &str,
|
||
source_hash: &str,
|
||
artifact_root_relative_path: &str,
|
||
source_map_root_relative_path: &str,
|
||
updated_at_ms: u128,
|
||
) -> Result<(), WebError> {
|
||
connection
|
||
.execute(
|
||
"INSERT OR REPLACE INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||
params![
|
||
resource_id,
|
||
owner_document_id,
|
||
owner_document_path,
|
||
source_root_relative_path,
|
||
provider,
|
||
source_hash,
|
||
artifact_root_relative_path,
|
||
source_map_root_relative_path,
|
||
updated_at_ms.to_string(),
|
||
],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn insert_evidence_block(
|
||
connection: &Connection,
|
||
block_id: &str,
|
||
resource_id: &str,
|
||
text: &str,
|
||
locator: Value,
|
||
) -> Result<(), WebError> {
|
||
insert_evidence_block_with_metadata(
|
||
connection,
|
||
block_id,
|
||
resource_id,
|
||
text,
|
||
Vec::new(),
|
||
None,
|
||
locator,
|
||
)
|
||
}
|
||
|
||
fn insert_evidence_block_with_metadata(
|
||
connection: &Connection,
|
||
block_id: &str,
|
||
resource_id: &str,
|
||
text: &str,
|
||
section_path: Vec<String>,
|
||
line_range: Option<Value>,
|
||
locator: Value,
|
||
) -> Result<(), WebError> {
|
||
let locator_json = serde_json::to_string(&locator).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_locator_serialize_failed",
|
||
format!("locator 序列化失败: {error}"),
|
||
)
|
||
})?;
|
||
let section_path_json = serde_json::to_string(§ion_path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_section_serialize_failed",
|
||
format!("sectionPath 序列化失败: {error}"),
|
||
)
|
||
})?;
|
||
let line_range_json = line_range
|
||
.map(|value| serde_json::to_string(&value))
|
||
.transpose()
|
||
.map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_line_range_serialize_failed",
|
||
format!("lineRange 序列化失败: {error}"),
|
||
)
|
||
})?;
|
||
connection
|
||
.execute(
|
||
"INSERT OR REPLACE INTO evidence_block(block_id, resource_id, text, section_path_json, page_start, page_end, bbox_json, char_range_json, line_range_json, locator_json) VALUES(?1, ?2, ?3, ?4, NULL, NULL, NULL, NULL, ?5, ?6)",
|
||
params![block_id, resource_id, text, section_path_json, line_range_json, locator_json],
|
||
)
|
||
.map_err(sqlite_error)?;
|
||
connection
|
||
.execute(
|
||
"INSERT INTO evidence_fts(block_id, text) VALUES(?1, ?2)",
|
||
params![block_id, text],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct MarkdownEvidenceBlock {
|
||
block_id: String,
|
||
text: String,
|
||
section_path: Vec<String>,
|
||
line_range: Value,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct MarkdownEvidenceSection {
|
||
section_id: String,
|
||
title: String,
|
||
path: Vec<String>,
|
||
line_start: u64,
|
||
parent_section_id: Option<String>,
|
||
}
|
||
|
||
fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceBlock> {
|
||
let mut headings: Vec<String> = Vec::new();
|
||
let mut blocks = Vec::new();
|
||
for (index, line) in document.raw_text.lines().enumerate() {
|
||
let line_number = (index + 1) as u64;
|
||
let trimmed = line.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
if let Some((level, title)) = markdown_heading(trimmed) {
|
||
headings.truncate(level.saturating_sub(1));
|
||
headings.push(title);
|
||
}
|
||
blocks.push(MarkdownEvidenceBlock {
|
||
block_id: format!("{}#line{}", document.document_id, line_number),
|
||
text: trimmed.to_string(),
|
||
section_path: headings.clone(),
|
||
line_range: json!({"start": line_number, "end": line_number}),
|
||
});
|
||
}
|
||
blocks
|
||
}
|
||
|
||
fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceSection> {
|
||
let mut headings: Vec<String> = Vec::new();
|
||
let mut sections = Vec::new();
|
||
for (index, line) in document.raw_text.lines().enumerate() {
|
||
let line_number = (index + 1) as u64;
|
||
let Some((level, title)) = markdown_heading(line.trim()) else {
|
||
continue;
|
||
};
|
||
headings.truncate(level.saturating_sub(1));
|
||
headings.push(title.clone());
|
||
let path = headings.clone();
|
||
let parent_section_id = if path.len() > 1 {
|
||
Some(format!(
|
||
"{}#section:{}",
|
||
document.document_id,
|
||
encode_local_id_segment(&path[..path.len() - 1].join("/"))
|
||
))
|
||
} else {
|
||
None
|
||
};
|
||
sections.push(MarkdownEvidenceSection {
|
||
section_id: format!(
|
||
"{}#section:{}",
|
||
document.document_id,
|
||
encode_local_id_segment(&path.join("/"))
|
||
),
|
||
title,
|
||
path,
|
||
line_start: line_number,
|
||
parent_section_id,
|
||
});
|
||
}
|
||
sections
|
||
}
|
||
|
||
fn markdown_heading(line: &str) -> Option<(usize, String)> {
|
||
let trimmed = line.trim_start();
|
||
let marker_count = trimmed.chars().take_while(|value| *value == '#').count();
|
||
if marker_count == 0 || marker_count > 6 {
|
||
return None;
|
||
}
|
||
let rest = trimmed.get(marker_count..)?.trim();
|
||
if rest.is_empty() {
|
||
return None;
|
||
}
|
||
Some((marker_count, rest.trim_matches('#').trim().to_string()))
|
||
}
|
||
|
||
fn insert_markdown_evidence_section(
|
||
connection: &Connection,
|
||
resource_id: &str,
|
||
section: &MarkdownEvidenceSection,
|
||
) -> Result<(), WebError> {
|
||
let path_json = serde_json::to_string(§ion.path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_section_serialize_failed",
|
||
format!("section path 序列化失败: {error}"),
|
||
)
|
||
})?;
|
||
connection
|
||
.execute(
|
||
"INSERT OR REPLACE INTO evidence_section(section_id, resource_id, title, path_json, summary, page_start, page_end, parent_section_id) VALUES(?1, ?2, ?3, ?4, NULL, ?5, ?5, ?6)",
|
||
params![
|
||
section.section_id,
|
||
resource_id,
|
||
section.title,
|
||
path_json,
|
||
section.line_start,
|
||
section.parent_section_id,
|
||
],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn insert_document_graph_edges(
|
||
connection: &Connection,
|
||
document: &LocalSearchDocument,
|
||
) -> Result<(), WebError> {
|
||
let source_block_id = first_markdown_evidence_block_id(document);
|
||
for target in &document.backlinks {
|
||
let to_id = markdown_reference_target_id(&document.path, target);
|
||
insert_evidence_edge(
|
||
connection,
|
||
&document.document_id,
|
||
&to_id,
|
||
"markdown_links_to",
|
||
&source_block_id,
|
||
)?;
|
||
}
|
||
for target in &document.resource_refs {
|
||
let to_id = resource_reference_target_id(&document.path, target);
|
||
insert_evidence_edge(
|
||
connection,
|
||
&document.document_id,
|
||
&to_id,
|
||
"resource_refers_to",
|
||
&source_block_id,
|
||
)?;
|
||
insert_evidence_edge(
|
||
connection,
|
||
&document.document_id,
|
||
&to_id,
|
||
"document_contains_resource",
|
||
&source_block_id,
|
||
)?;
|
||
}
|
||
for mention in markdown_mention_edges(document) {
|
||
insert_evidence_edge(
|
||
connection,
|
||
&document.document_id,
|
||
&mention.target_id,
|
||
"mentions",
|
||
&mention.source_block_id,
|
||
)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
|
||
document
|
||
.raw_text
|
||
.lines()
|
||
.enumerate()
|
||
.find(|(_, line)| !line.trim().is_empty())
|
||
.map(|(index, _)| format!("{}#line{}", document.document_id, index + 1))
|
||
.unwrap_or_else(|| format!("{}#line1", document.document_id))
|
||
}
|
||
|
||
fn insert_evidence_edge(
|
||
connection: &Connection,
|
||
from_id: &str,
|
||
to_id: &str,
|
||
edge_type: &str,
|
||
source_block_id: &str,
|
||
) -> Result<(), WebError> {
|
||
let has_source_locator = connection
|
||
.query_row(
|
||
"SELECT 1 FROM evidence_block WHERE block_id = ?1 AND locator_json <> '' LIMIT 1",
|
||
params![source_block_id],
|
||
|_| Ok(true),
|
||
)
|
||
.optional()
|
||
.map_err(sqlite_error)?
|
||
.unwrap_or(false);
|
||
if !has_source_locator {
|
||
return Ok(());
|
||
}
|
||
let edge_id = format!(
|
||
"{}:{}:{}",
|
||
edge_type,
|
||
encode_local_id_segment(from_id),
|
||
encode_local_id_segment(to_id)
|
||
);
|
||
connection
|
||
.execute(
|
||
"INSERT OR REPLACE INTO evidence_edge(edge_id, from_id, to_id, edge_type, source_block_id, confidence, created_by) VALUES(?1, ?2, ?3, ?4, ?5, 1.0, 'deterministic_markdown')",
|
||
params![edge_id, from_id, to_id, edge_type, source_block_id],
|
||
)
|
||
.map(|_| ())
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
|
||
let target = target.trim();
|
||
if target.starts_with("local-md:") {
|
||
return target.to_string();
|
||
}
|
||
if is_markdown_reference(target) {
|
||
return format!(
|
||
"local-md:{}",
|
||
encode_local_id_segment(&normalize_local_reference_path(owner_path, target))
|
||
);
|
||
}
|
||
format!("markdown-title:{}", encode_local_id_segment(target))
|
||
}
|
||
|
||
fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
|
||
format!(
|
||
"local-resource:{}",
|
||
encode_local_id_segment(&normalize_local_reference_path(owner_path, target))
|
||
)
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct MarkdownMentionEdge {
|
||
target_id: String,
|
||
source_block_id: String,
|
||
}
|
||
|
||
fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMentionEdge> {
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
let mut mentions = Vec::new();
|
||
for (line_index, line) in document.raw_text.lines().enumerate() {
|
||
let source_block_id = format!("{}#line{}", document.document_id, line_index + 1);
|
||
for mention in extract_line_mentions(line) {
|
||
let target_id = format!("entity:{}", encode_local_id_segment(&mention));
|
||
if seen.insert((target_id.clone(), source_block_id.clone())) {
|
||
mentions.push(MarkdownMentionEdge {
|
||
target_id,
|
||
source_block_id: source_block_id.clone(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
mentions
|
||
}
|
||
|
||
fn extract_line_mentions(line: &str) -> Vec<String> {
|
||
let chars = line.chars().collect::<Vec<_>>();
|
||
let mut mentions = Vec::new();
|
||
let mut index = 0;
|
||
while index < chars.len() {
|
||
if chars[index] != '@' {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if index > 0 && chars[index - 1].is_alphanumeric() {
|
||
index += 1;
|
||
continue;
|
||
}
|
||
let mut end = index + 1;
|
||
while end < chars.len()
|
||
&& (chars[end].is_alphanumeric()
|
||
|| chars[end] == '_'
|
||
|| chars[end] == '-'
|
||
|| chars[end] == '.')
|
||
{
|
||
end += 1;
|
||
}
|
||
if end > index + 1 {
|
||
let value = chars[index + 1..end].iter().collect::<String>();
|
||
mentions.push(value);
|
||
}
|
||
index = end.max(index + 1);
|
||
}
|
||
mentions.sort();
|
||
mentions.dedup();
|
||
mentions
|
||
}
|
||
|
||
fn markdown_locator(
|
||
index: &LocalSearchIndex,
|
||
document_id: &str,
|
||
path: &str,
|
||
block_id: &str,
|
||
section_path: Vec<String>,
|
||
line_range: Option<Value>,
|
||
) -> Value {
|
||
json!({
|
||
"schema": "mnote.evidence_locator.v1",
|
||
"rootUri": index.root_uri,
|
||
"ownerDocumentId": document_id,
|
||
"ownerDocumentPath": path,
|
||
"resourcePath": path,
|
||
"resourceKind": "markdown",
|
||
"sectionPath": section_path,
|
||
"lineRange": line_range,
|
||
"blockId": block_id,
|
||
"openAction": {
|
||
"actionType": "mnote.open_resource_locator",
|
||
"url": format!("/documents/{document_id}?sourceKind=local_folder&rootUri={}", encode_query_component(&index.root_uri)),
|
||
"params": {"resourcePath": path, "blockId": block_id, "lineRange": line_range}
|
||
}
|
||
})
|
||
}
|
||
|
||
fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
|
||
root_path
|
||
.join(".mnote")
|
||
.join("index")
|
||
.join("evidence.sqlite")
|
||
}
|
||
|
||
fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
|
||
let connection = Connection::open(path).map_err(|error| {
|
||
WebError::bad_request_code(
|
||
"evidence_index_open_failed",
|
||
format!("无法打开 evidence 索引 {}: {error}", path.display()),
|
||
)
|
||
})?;
|
||
connection
|
||
.query_row("SELECT COUNT(*) FROM evidence_block", [], |row| {
|
||
row.get::<_, u64>(0)
|
||
})
|
||
.map_err(sqlite_error)
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct LocalEvidenceSourceStatuses {
|
||
pub(crate) indexed_paths: BTreeSet<String>,
|
||
pub(crate) failed_paths: BTreeSet<String>,
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
Ok(statuses)
|
||
}
|
||
|
||
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
|
||
match resource_type {
|
||
"mindmap" => "mindmap",
|
||
"office" => "office",
|
||
"pdf" => "pdf",
|
||
"image" => "image",
|
||
"markdown" => "markdown",
|
||
_ => "raw_file",
|
||
}
|
||
}
|
||
|
||
fn evidence_resource_kind_for_path(path: &str) -> &'static str {
|
||
match Path::new(path)
|
||
.extension()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or_default()
|
||
.to_ascii_lowercase()
|
||
.as_str()
|
||
{
|
||
"pdf" => "pdf",
|
||
"png" | "jpg" | "jpeg" | "webp" => "image",
|
||
"doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" => "office",
|
||
"md" => "markdown",
|
||
"mm" | "mindmap" => "mindmap",
|
||
_ => "raw_file",
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
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"));
|
||
}
|
||
if let Some(stripped) = path.strip_suffix(".parse.md") {
|
||
return Some(format!("{stripped}.source-map.json"));
|
||
}
|
||
None
|
||
}
|
||
|
||
fn is_local_evidence_sidecar_relative_path(relative_path: &str) -> bool {
|
||
let normalized = relative_path.trim().replace('\\', "/");
|
||
let path = Path::new(&normalized);
|
||
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
|
||
return false;
|
||
};
|
||
let Some(parent_name) = path
|
||
.parent()
|
||
.and_then(|value| value.file_name())
|
||
.and_then(|value| value.to_str())
|
||
else {
|
||
return false;
|
||
};
|
||
parent_name.ends_with(".ocr")
|
||
&& (file_name.ends_with(".parse.md") || file_name.ends_with(".source-map.json"))
|
||
}
|
||
|
||
fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
|
||
let target = target
|
||
.split('#')
|
||
.next()
|
||
.unwrap_or(target)
|
||
.split('?')
|
||
.next()
|
||
.unwrap_or(target)
|
||
.trim();
|
||
if target.starts_with('/') {
|
||
return target.trim_start_matches('/').replace('\\', "/");
|
||
}
|
||
let owner_parent = Path::new(owner_path)
|
||
.parent()
|
||
.unwrap_or_else(|| Path::new(""));
|
||
normalize_path_components(owner_parent.join(target))
|
||
}
|
||
|
||
fn normalize_path_components(path: PathBuf) -> String {
|
||
let mut parts = Vec::new();
|
||
for component in path.components() {
|
||
match component {
|
||
Component::Normal(value) => parts.push(value.to_string_lossy().to_string()),
|
||
Component::ParentDir => {
|
||
parts.pop();
|
||
}
|
||
Component::CurDir => {}
|
||
_ => {}
|
||
}
|
||
}
|
||
parts.join("/")
|
||
}
|
||
|
||
fn sqlite_error(error: rusqlite::Error) -> WebError {
|
||
WebError::bad_request_code(
|
||
"evidence_index_sqlite_error",
|
||
format!("evidence SQLite 错误: {error}"),
|
||
)
|
||
}
|
||
|
||
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.contains(query)
|
||
} else {
|
||
token_search_match(&haystack, 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.contains(query)
|
||
} else {
|
||
token_search_match(&haystack, query)
|
||
}
|
||
}
|
||
|
||
fn local_search_document_projection(
|
||
document: &LocalSearchDocument,
|
||
root_uri: &str,
|
||
query: &str,
|
||
) -> Value {
|
||
let hit = search_document_hit(document, query);
|
||
json!({
|
||
"id": document.document_id,
|
||
"documentId": document.document_id,
|
||
"title": document.title,
|
||
"path": document.path,
|
||
"resourceType": "markdown",
|
||
"sourceKind": "local_folder",
|
||
"rootUri": root_uri,
|
||
"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,
|
||
"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 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 {
|
||
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) {
|
||
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),
|
||
};
|
||
}
|
||
}
|
||
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),
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct EvidenceQueryTerm {
|
||
display: String,
|
||
normalized: String,
|
||
alternatives: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct EvidenceTextMatch {
|
||
score: f64,
|
||
matched_terms: Vec<String>,
|
||
missing_terms: Vec<String>,
|
||
match_mode: String,
|
||
strong: bool,
|
||
}
|
||
|
||
impl EvidenceTextMatch {
|
||
fn into_match_info(self, rank: Option<u32>) -> EvidenceSearchMatchInfo {
|
||
EvidenceSearchMatchInfo {
|
||
rank,
|
||
matched_terms: self.matched_terms,
|
||
missing_terms: self.missing_terms,
|
||
match_mode: self.match_mode,
|
||
match_scope: "block".into(),
|
||
strong: self.strong,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn score_evidence_text_match(text: &str, query: &str) -> Option<EvidenceTextMatch> {
|
||
let normalized_text = normalize_search_text(text);
|
||
let normalized_query = normalize_search_text(query);
|
||
if normalized_query.is_empty() {
|
||
return None;
|
||
}
|
||
let terms = evidence_query_terms(&normalized_query);
|
||
if terms.is_empty() {
|
||
return None;
|
||
}
|
||
let phrase_exact = normalized_text.contains(&normalized_query);
|
||
let mut matched_terms = Vec::new();
|
||
let mut missing_terms = Vec::new();
|
||
let mut exact_count = 0usize;
|
||
let mut partial_count = 0usize;
|
||
let mut positions = Vec::new();
|
||
|
||
for term in &terms {
|
||
if let Some(index) = normalized_text.find(&term.normalized) {
|
||
exact_count += 1;
|
||
positions.push(index);
|
||
push_unique(&mut matched_terms, term.display.clone());
|
||
continue;
|
||
}
|
||
let partial_matches = term
|
||
.alternatives
|
||
.iter()
|
||
.filter_map(|alternative| {
|
||
normalized_text
|
||
.find(alternative)
|
||
.map(|index| (alternative.clone(), index))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let partial_allowed = !partial_matches.is_empty()
|
||
&& if terms.len() > 1 {
|
||
true
|
||
} else {
|
||
partial_matches.len() >= 2
|
||
};
|
||
if partial_allowed {
|
||
partial_count += 1;
|
||
if let Some((_, index)) = partial_matches.first() {
|
||
positions.push(*index);
|
||
}
|
||
for (alternative, _) in partial_matches {
|
||
push_unique(&mut matched_terms, alternative);
|
||
}
|
||
push_unique(&mut missing_terms, term.display.clone());
|
||
} else {
|
||
return None;
|
||
}
|
||
}
|
||
|
||
let term_count = terms.len().max(1) as f64;
|
||
let coverage = (exact_count as f64 + partial_count as f64 * 0.45) / term_count;
|
||
let proximity = if positions.len() > 1 {
|
||
let min = positions.iter().min().copied().unwrap_or(0);
|
||
let max = positions.iter().max().copied().unwrap_or(min);
|
||
let span = max.saturating_sub(min);
|
||
if span <= 80 {
|
||
0.1
|
||
} else if span <= 240 {
|
||
0.05
|
||
} else {
|
||
0.0
|
||
}
|
||
} else {
|
||
0.0
|
||
};
|
||
let score = if phrase_exact {
|
||
1.0
|
||
} else {
|
||
(coverage * 0.85 + proximity).min(0.95)
|
||
};
|
||
let match_mode = if phrase_exact {
|
||
"exact_phrase"
|
||
} else if partial_count == 0 {
|
||
"all_terms"
|
||
} else {
|
||
"cjk_partial"
|
||
};
|
||
Some(EvidenceTextMatch {
|
||
score,
|
||
matched_terms,
|
||
missing_terms,
|
||
match_mode: match_mode.into(),
|
||
strong: phrase_exact || partial_count == 0 && exact_count == terms.len(),
|
||
})
|
||
}
|
||
|
||
fn evidence_query_terms(query: &str) -> Vec<EvidenceQueryTerm> {
|
||
split_search_query_tokens(query)
|
||
.into_iter()
|
||
.filter(|token| !token.is_empty())
|
||
.map(|token| {
|
||
let alternatives = cjk_token_bigrams(&token)
|
||
.into_iter()
|
||
.filter(|alternative| alternative != &token)
|
||
.collect::<Vec<_>>();
|
||
EvidenceQueryTerm {
|
||
display: token.clone(),
|
||
normalized: token,
|
||
alternatives,
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn split_search_query_tokens(query: &str) -> Vec<String> {
|
||
let mut tokens = Vec::new();
|
||
let mut current = String::new();
|
||
for ch in query.chars() {
|
||
if ch.is_whitespace() || is_search_separator(ch) {
|
||
if !current.is_empty() {
|
||
tokens.push(std::mem::take(&mut current));
|
||
}
|
||
} else {
|
||
current.push(ch);
|
||
}
|
||
}
|
||
if !current.is_empty() {
|
||
tokens.push(current);
|
||
}
|
||
tokens
|
||
}
|
||
|
||
fn is_search_separator(ch: char) -> bool {
|
||
matches!(
|
||
ch,
|
||
',' | ';' | ':' | ',' | ';' | ':' | '。' | '、' | '(' | ')' | '[' | ']' | '{' | '}'
|
||
)
|
||
}
|
||
|
||
fn cjk_token_bigrams(token: &str) -> Vec<String> {
|
||
let chars = token.chars().collect::<Vec<_>>();
|
||
if chars.len() < 3 || !chars.iter().any(|ch| is_cjk_char(*ch)) {
|
||
return Vec::new();
|
||
}
|
||
chars
|
||
.windows(2)
|
||
.filter_map(|window| {
|
||
if window.iter().any(|ch| is_cjk_stop_char(*ch)) {
|
||
return None;
|
||
}
|
||
Some(window.iter().collect::<String>())
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn is_cjk_char(ch: char) -> bool {
|
||
matches!(
|
||
ch as u32,
|
||
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF
|
||
)
|
||
}
|
||
|
||
fn is_cjk_stop_char(ch: char) -> bool {
|
||
matches!(
|
||
ch,
|
||
'的' | '了' | '和' | '与' | '及' | '在' | '是' | '为' | '对' | '中'
|
||
)
|
||
}
|
||
|
||
fn push_unique(values: &mut Vec<String>, value: String) {
|
||
if !value.is_empty() && !values.iter().any(|existing| existing == &value) {
|
||
values.push(value);
|
||
}
|
||
}
|
||
|
||
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 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 ocr_search_snippet_for_terms(body: &str, query: &str, terms: &[String]) -> String {
|
||
let normalized_body = body.replace('\n', " ");
|
||
let normalized_query = query.trim();
|
||
if !normalized_query.is_empty() {
|
||
let lower = normalized_body.to_lowercase();
|
||
let lower_query = normalized_query.to_lowercase();
|
||
if let Some(byte_index) = lower.find(&lower_query) {
|
||
return snippet_from_byte_index(&normalized_body, byte_index, 180);
|
||
}
|
||
}
|
||
let lower = normalized_body.to_lowercase();
|
||
for term in terms {
|
||
let term = term.trim().to_lowercase();
|
||
if term.is_empty() {
|
||
continue;
|
||
}
|
||
if let Some(byte_index) = lower.find(&term) {
|
||
return snippet_from_byte_index(&normalized_body, byte_index, 180);
|
||
}
|
||
}
|
||
ocr_search_snippet(body, query)
|
||
}
|
||
|
||
fn snippet_from_byte_index(body: &str, byte_index: usize, len: usize) -> String {
|
||
let start = body[..byte_index]
|
||
.char_indices()
|
||
.rev()
|
||
.nth(40)
|
||
.map(|(idx, _)| idx)
|
||
.unwrap_or(0);
|
||
body[start..].chars().take(len).collect()
|
||
}
|
||
|
||
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 token_search_match(haystack: &str, query: &str) -> bool {
|
||
score_evidence_text_match(haystack, query).is_some()
|
||
}
|
||
|
||
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() {
|
||
"png" | "jpg" | "jpeg" | "webp" | "bmp" | "gif" | "tif" | "tiff" | "svg" => Some("image"),
|
||
"pdf" => Some("pdf"),
|
||
"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 control_plane::SqliteControlPlaneStore;
|
||
use std::path::PathBuf;
|
||
use std::sync::{Mutex, OnceLock};
|
||
|
||
fn env_lock() -> &'static Mutex<()> {
|
||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
LOCK.get_or_init(|| Mutex::new(()))
|
||
}
|
||
|
||
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\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::create_dir_all(root.join("assets")).expect("create assets");
|
||
fs::write(root.join("assets").join("spec.pdf"), b"%PDF-1.4\n").expect("write pdf");
|
||
fs::write(root.join("assets").join("diagram.png"), b"png").expect("write image");
|
||
fs::write(root.join("office").join("report.xlsx"), b"office bytes").expect("write office");
|
||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||
.expect("settings");
|
||
|
||
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"
|
||
)));
|
||
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
|
||
let connection = Connection::open(&evidence_db).expect("open evidence sqlite");
|
||
let markdown_edge_count: i64 = connection
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM evidence_edge WHERE edge_type = 'markdown_links_to'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("markdown edge count");
|
||
let resource_edge_count: i64 = connection
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM evidence_edge WHERE edge_type = 'resource_refers_to'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("resource edge count");
|
||
let contains_edge_count: i64 = connection
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM evidence_edge WHERE edge_type = 'document_contains_resource'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("contains edge count");
|
||
assert!(markdown_edge_count >= 2);
|
||
assert!(resource_edge_count >= 4);
|
||
assert!(contains_edge_count >= 4);
|
||
|
||
// 含 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 pdf_projection = query_local_search_index(
|
||
&root,
|
||
&format!("file://{}", root.display()),
|
||
"local-ws-test",
|
||
"spec.pdf",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("pdf projection");
|
||
assert!(pdf_projection["results"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|item| item["resourceType"].as_str() == Some("pdf")
|
||
&& item["path"].as_str() == Some("assets/spec.pdf")));
|
||
let image_projection = query_local_search_index(
|
||
&root,
|
||
&format!("file://{}", root.display()),
|
||
"local-ws-test",
|
||
"diagram.png",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("image projection");
|
||
assert!(image_projection["results"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|item| item["resourceType"].as_str() == Some("image")
|
||
&& item["path"].as_str() == Some("assets/diagram.png")));
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_search_two_char_cjk_query_requires_real_match() {
|
||
assert!(score_evidence_text_match("13. N -甲基吗啉 N -氧化物", "吗啉").is_some());
|
||
assert!(score_evidence_text_match("Scope Alpha SOURCE SCOPE RAG", "吗啉").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn local_index_settings_restricts_search_and_evidence_scope() {
|
||
let root = temp_root("mnote-local-index-settings-scope");
|
||
fs::create_dir_all(root.join("docs").join("included")).expect("create included");
|
||
fs::create_dir_all(root.join("docs").join("excluded")).expect("create excluded");
|
||
fs::write(
|
||
root.join("docs").join("included").join("keep.md"),
|
||
"# Keep\nScopedToken\n",
|
||
)
|
||
.expect("write included");
|
||
fs::write(
|
||
root.join("docs").join("excluded").join("skip.md"),
|
||
"# Skip\nExcludedToken\n",
|
||
)
|
||
.expect("write excluded");
|
||
let root_uri = format!("file://{}", root.display());
|
||
write_local_index_settings(
|
||
&root,
|
||
&[String::from("docs/included")],
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.expect("settings");
|
||
refresh_local_search_index(&root, &root_uri, "local-ws-scope").expect("refresh");
|
||
|
||
let included = query_local_search_index(
|
||
&root,
|
||
&root_uri,
|
||
"local-ws-scope",
|
||
"ScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("included query");
|
||
assert_eq!(included["results"].as_array().unwrap().len(), 1);
|
||
let excluded = query_local_search_index(
|
||
&root,
|
||
&root_uri,
|
||
"local-ws-scope",
|
||
"ExcludedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("excluded query");
|
||
assert_eq!(excluded["results"].as_array().unwrap().len(), 0);
|
||
let evidence = query_evidence_sqlite_results(&root, "ScopedToken", None, 10)
|
||
.expect("evidence query")
|
||
.expect("evidence index");
|
||
assert_eq!(evidence.len(), 1);
|
||
let status = local_index_status(&root, &root_uri, "local-ws-scope").expect("status");
|
||
assert_eq!(
|
||
status["settings"]["includePaths"].as_array().unwrap()[0].as_str(),
|
||
Some("docs/included")
|
||
);
|
||
assert_eq!(status["documentCount"].as_u64(), Some(1));
|
||
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");
|
||
let escape = write_local_index_settings(
|
||
&root,
|
||
&[String::from("../outside")],
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.expect_err("escape should fail");
|
||
assert_eq!(escape.code(), "local_search_index_path_escape");
|
||
let internal = write_local_index_settings(
|
||
&root,
|
||
&[String::from(".mnote/index")],
|
||
None,
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.expect_err("internal cache should fail");
|
||
assert_eq!(internal.code(), "local_index_scope_reserved");
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_index_settings_defaults_and_schedule_are_persisted() {
|
||
let root = temp_root("mnote-local-index-settings-schedule");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let initial_status =
|
||
local_index_status(&root, &root_uri, "local-ws-schedule").expect("initial status");
|
||
assert_eq!(
|
||
initial_status["settings"]["scheduleMode"].as_str(),
|
||
Some("daily")
|
||
);
|
||
assert_eq!(
|
||
initial_status["settings"]["scheduleTime"].as_str(),
|
||
Some("02:00")
|
||
);
|
||
assert_eq!(
|
||
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,
|
||
&[String::from("docs")],
|
||
Some("once"),
|
||
Some("08:30"),
|
||
Some("2026-06-04"),
|
||
Some(true),
|
||
)
|
||
.expect("write schedule settings");
|
||
assert_eq!(settings.include_paths, vec![String::from("docs")]);
|
||
assert_eq!(settings.schedule_mode, "once");
|
||
assert_eq!(settings.schedule_time, "08:30");
|
||
assert_eq!(settings.schedule_date.as_deref(), Some("2026-06-04"));
|
||
assert!(settings.run_on_change);
|
||
|
||
let status = local_index_status(&root, &root_uri, "local-ws-schedule").expect("status");
|
||
assert_eq!(status["settings"]["scheduleMode"].as_str(), Some("once"));
|
||
assert_eq!(status["settings"]["scheduleTime"].as_str(), Some("08:30"));
|
||
assert_eq!(
|
||
status["settings"]["scheduleDate"].as_str(),
|
||
Some("2026-06-04")
|
||
);
|
||
assert_eq!(status["settings"]["runOnChange"].as_bool(), Some(true));
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_index_settings_rejects_invalid_schedule() {
|
||
let root = temp_root("mnote-local-index-settings-invalid-schedule");
|
||
let invalid_mode = write_local_index_settings(
|
||
&root,
|
||
&[String::from(".")],
|
||
Some("weekly"),
|
||
None,
|
||
None,
|
||
None,
|
||
)
|
||
.expect_err("invalid mode should fail");
|
||
assert_eq!(invalid_mode.code(), "local_index_schedule_mode_invalid");
|
||
|
||
let invalid_time = write_local_index_settings(
|
||
&root,
|
||
&[String::from(".")],
|
||
Some("daily"),
|
||
Some("24:00"),
|
||
None,
|
||
None,
|
||
)
|
||
.expect_err("invalid time should fail");
|
||
assert_eq!(invalid_time.code(), "local_index_schedule_time_invalid");
|
||
|
||
let invalid_date = write_local_index_settings(
|
||
&root,
|
||
&[String::from(".")],
|
||
Some("once"),
|
||
Some("02:00"),
|
||
Some("2026-13-01"),
|
||
None,
|
||
)
|
||
.expect_err("invalid date should fail");
|
||
assert_eq!(invalid_date.code(), "local_index_schedule_date_invalid");
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_index_schedule_due_respects_modes() {
|
||
let today = OffsetDateTime::now_local()
|
||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||
.date();
|
||
let yesterday = today - Duration::days(1);
|
||
let tomorrow = today + Duration::days(1);
|
||
let yesterday = format!(
|
||
"{:04}-{:02}-{:02}",
|
||
yesterday.year(),
|
||
u8::from(yesterday.month()),
|
||
yesterday.day()
|
||
);
|
||
let tomorrow = format!(
|
||
"{:04}-{:02}-{:02}",
|
||
tomorrow.year(),
|
||
u8::from(tomorrow.month()),
|
||
tomorrow.day()
|
||
);
|
||
let mut settings = default_local_index_settings();
|
||
|
||
settings.schedule_mode = "manual".to_string();
|
||
assert!(!local_index_schedule_is_due(&settings, 0));
|
||
|
||
settings.schedule_mode = "once".to_string();
|
||
settings.schedule_time = "00:00".to_string();
|
||
settings.schedule_date = Some(yesterday);
|
||
assert!(local_index_schedule_is_due(&settings, 0));
|
||
|
||
settings.schedule_date = Some(tomorrow);
|
||
assert!(!local_index_schedule_is_due(&settings, 0));
|
||
|
||
settings.schedule_mode = "daily".to_string();
|
||
settings.schedule_date = None;
|
||
settings.schedule_time = "00:00".to_string();
|
||
assert!(local_index_schedule_is_due(&settings, 0));
|
||
assert!(!local_index_schedule_is_due(&settings, now_ms()));
|
||
}
|
||
|
||
#[test]
|
||
fn multi_user_local_index_settings_share_one_cache_and_filter_results() {
|
||
let root = temp_root("mnote-local-index-multi-user");
|
||
fs::create_dir_all(root.join("docs").join("alice")).expect("create alice dir");
|
||
fs::create_dir_all(root.join("docs").join("bob")).expect("create bob dir");
|
||
fs::write(
|
||
root.join("docs").join("alice").join("keep.md"),
|
||
"# Alice\nAliceScopedToken\n",
|
||
)
|
||
.expect("write alice doc");
|
||
fs::write(
|
||
root.join("docs").join("bob").join("keep.md"),
|
||
"# Bob\nBobScopedToken\n",
|
||
)
|
||
.expect("write bob doc");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-multi-user";
|
||
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||
|
||
write_user_local_index_settings(
|
||
&store,
|
||
"alice",
|
||
workspace_id,
|
||
&root,
|
||
&[String::from("docs/alice")],
|
||
Some("manual"),
|
||
Some("02:00"),
|
||
None,
|
||
Some(false),
|
||
)
|
||
.expect("write alice settings");
|
||
write_user_local_index_settings(
|
||
&store,
|
||
"bob",
|
||
workspace_id,
|
||
&root,
|
||
&[String::from("docs/bob")],
|
||
Some("manual"),
|
||
Some("02:00"),
|
||
None,
|
||
Some(true),
|
||
)
|
||
.expect("write bob settings");
|
||
|
||
let sqlite_records = store
|
||
.list_user_ui_preferences_for_scope(
|
||
Some(workspace_id),
|
||
Some(LOCAL_INDEX_SOURCE_KIND),
|
||
LOCAL_INDEX_SETTINGS_SCOPE_KIND,
|
||
&local_index_scope_id(&root),
|
||
LOCAL_INDEX_SETTINGS_KEY,
|
||
)
|
||
.expect("read raw sqlite preference rows");
|
||
assert_eq!(sqlite_records.len(), 2);
|
||
let alice_sqlite_record = sqlite_records
|
||
.iter()
|
||
.find(|record| record.user_id == "alice")
|
||
.expect("alice sqlite preference row");
|
||
let alice_sqlite_value: serde_json::Value =
|
||
serde_json::from_str(&alice_sqlite_record.value_json).expect("alice sqlite json");
|
||
assert_eq!(
|
||
alice_sqlite_value["includePaths"],
|
||
serde_json::json!(["docs/alice"]),
|
||
"索引范围必须写入 SQLite user_ui_preferences.value_json"
|
||
);
|
||
|
||
let alice_settings =
|
||
read_user_local_index_settings(&store, "alice", workspace_id, &root).expect("alice");
|
||
let bob_settings =
|
||
read_user_local_index_settings(&store, "bob", workspace_id, &root).expect("bob");
|
||
let effective = effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||
.expect("effective");
|
||
|
||
assert_eq!(
|
||
effective.include_paths,
|
||
vec![String::from("docs/alice"), String::from("docs/bob")]
|
||
);
|
||
assert!(effective.run_on_change);
|
||
|
||
refresh_local_search_index_with_settings(&root, &root_uri, workspace_id, &effective)
|
||
.expect("refresh shared index");
|
||
|
||
let alice_results = query_local_search_index_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&effective,
|
||
&alice_settings,
|
||
"AliceScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("alice query");
|
||
assert_eq!(alice_results["results"].as_array().map(Vec::len), Some(1));
|
||
|
||
let alice_hidden = query_local_search_index_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&effective,
|
||
&alice_settings,
|
||
"BobScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("alice hidden query");
|
||
assert_eq!(alice_hidden["results"].as_array().map(Vec::len), Some(0));
|
||
|
||
let bob_results = query_local_search_index_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&effective,
|
||
&bob_settings,
|
||
"BobScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("bob query");
|
||
assert_eq!(bob_results["results"].as_array().map(Vec::len), Some(1));
|
||
|
||
let status = local_index_status_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&alice_settings,
|
||
&effective,
|
||
)
|
||
.expect("status");
|
||
assert_eq!(
|
||
status["settings"]["includePaths"].as_array().map(Vec::len),
|
||
Some(1)
|
||
);
|
||
assert_eq!(
|
||
status["effectiveSettings"]["includePaths"]
|
||
.as_array()
|
||
.map(Vec::len),
|
||
Some(2)
|
||
);
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_index_schedule_due_uses_any_user_schedule() {
|
||
let root = temp_root("mnote-local-index-schedule-any-user");
|
||
let workspace_id = "local-ws-schedule-any-user";
|
||
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||
let today = OffsetDateTime::now_local()
|
||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||
.date();
|
||
let yesterday = today - Duration::days(1);
|
||
let yesterday = format!(
|
||
"{:04}-{:02}-{:02}",
|
||
yesterday.year(),
|
||
u8::from(yesterday.month()),
|
||
yesterday.day()
|
||
);
|
||
|
||
write_user_local_index_settings(
|
||
&store,
|
||
"alice",
|
||
workspace_id,
|
||
&root,
|
||
&[String::from(".")],
|
||
Some("manual"),
|
||
Some("02:00"),
|
||
None,
|
||
Some(false),
|
||
)
|
||
.expect("write alice settings");
|
||
write_user_local_index_settings(
|
||
&store,
|
||
"bob",
|
||
workspace_id,
|
||
&root,
|
||
&[String::from(".")],
|
||
Some("once"),
|
||
Some("00:00"),
|
||
Some(&yesterday),
|
||
Some(false),
|
||
)
|
||
.expect("write bob settings");
|
||
|
||
assert!(
|
||
local_index_any_schedule_due_for_root(&store, workspace_id, &root, 0)
|
||
.expect("schedule due"),
|
||
"任一用户到期就应该触发共享索引重建"
|
||
);
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn refresh_local_search_index_for_change_path_with_store_uses_sqlite_effective_settings() {
|
||
let root = temp_root("mnote-local-index-change-with-store");
|
||
fs::create_dir_all(root.join("docs").join("included")).expect("create included");
|
||
fs::create_dir_all(root.join("docs").join("excluded")).expect("create excluded");
|
||
fs::write(
|
||
root.join("docs").join("included").join("keep.md"),
|
||
"# Keep\nKeepScopedToken\n",
|
||
)
|
||
.expect("write included");
|
||
fs::write(
|
||
root.join("docs").join("excluded").join("skip.md"),
|
||
"# Skip\nSkipScopedToken\n",
|
||
)
|
||
.expect("write excluded");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-change-with-store";
|
||
let store = SqliteControlPlaneStore::in_memory().expect("init control plane");
|
||
|
||
write_local_index_settings(
|
||
&root,
|
||
&[String::from("docs/excluded")],
|
||
Some("manual"),
|
||
Some("02:00"),
|
||
None,
|
||
Some(false),
|
||
)
|
||
.expect("write stale cache settings");
|
||
write_user_local_index_settings(
|
||
&store,
|
||
"alice",
|
||
workspace_id,
|
||
&root,
|
||
&[String::from("docs/included")],
|
||
Some("manual"),
|
||
Some("02:00"),
|
||
None,
|
||
Some(true),
|
||
)
|
||
.expect("write sqlite settings");
|
||
|
||
let refreshed = refresh_local_search_index_for_change_path_with_store(
|
||
&store,
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
"docs/included/keep.md",
|
||
)
|
||
.expect("refresh")
|
||
.expect("refresh result");
|
||
assert_eq!(refreshed["documentCount"].as_u64(), Some(1));
|
||
|
||
let keep_results = query_local_search_index_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||
.expect("effective"),
|
||
&read_user_local_index_settings(&store, "alice", workspace_id, &root).expect("alice"),
|
||
"KeepScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("keep query");
|
||
assert_eq!(keep_results["results"].as_array().map(Vec::len), Some(1));
|
||
|
||
let skip_results = query_local_search_index_with_settings(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
&effective_local_index_settings_for_root(&store, workspace_id, &root)
|
||
.expect("effective"),
|
||
&read_user_local_index_settings(&store, "alice", workspace_id, &root).expect("alice"),
|
||
"SkipScopedToken",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("skip query");
|
||
assert_eq!(skip_results["results"].as_array().map(Vec::len), Some(0));
|
||
|
||
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");
|
||
|
||
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"),
|
||
"---\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"));
|
||
let changed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10)
|
||
.expect("changed evidence")
|
||
.expect("sqlite exists");
|
||
assert_eq!(changed_evidence.len(), 1);
|
||
assert_eq!(
|
||
changed_evidence[0].source.owner_document_path,
|
||
"docs/child.md"
|
||
);
|
||
let original_evidence = query_evidence_sqlite_results(&root, "Original body", None, 10)
|
||
.expect("original evidence")
|
||
.expect("sqlite exists");
|
||
assert!(original_evidence.is_empty());
|
||
|
||
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 removed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10)
|
||
.expect("removed evidence")
|
||
.expect("sqlite exists");
|
||
assert!(removed_evidence.is_empty());
|
||
let root_evidence = query_evidence_sqlite_results(&root, "Root body", None, 10)
|
||
.expect("root evidence")
|
||
.expect("sqlite exists");
|
||
assert_eq!(root_evidence.len(), 1);
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn local_search_ocr_sidecar_is_hidden_after_lightrag_retirement() {
|
||
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");
|
||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||
.expect("settings");
|
||
|
||
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");
|
||
assert_eq!(with_ocr["results"].as_array().map(Vec::len), Some(0));
|
||
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 refreshed_evidence = query_evidence_sqlite_results(&root, "OCR-hash-token", None, 10)
|
||
.expect("query refreshed evidence")
|
||
.unwrap_or_default();
|
||
assert!(refreshed_evidence.is_empty());
|
||
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");
|
||
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
|
||
.expect("settings");
|
||
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn evidence_sqlite_query_returns_locator_results() {
|
||
let root = temp_root("mnote-evidence-sqlite-query");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-evidence-sqlite";
|
||
fs::write(
|
||
root.join("README.md"),
|
||
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @Reasonix for citation.\n",
|
||
)
|
||
.expect("write home");
|
||
fs::write(
|
||
root.join("docs").join("child.md"),
|
||
"# Child\nEvidenceToken child body.\n",
|
||
)
|
||
.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)
|
||
.expect("sqlite query")
|
||
.expect("sqlite exists");
|
||
assert!(results.len() >= 2);
|
||
assert!(results
|
||
.iter()
|
||
.any(|result| result.source.owner_document_path == "README.md"));
|
||
assert!(results
|
||
.iter()
|
||
.any(|result| result.source.owner_document_path == "docs/child.md"));
|
||
assert!(results
|
||
.iter()
|
||
.all(|result| result.source.schema == "mnote.evidence_locator.v1"));
|
||
let home_result = results
|
||
.iter()
|
||
.find(|result| result.source.owner_document_path == "README.md")
|
||
.expect("home evidence result");
|
||
assert_eq!(
|
||
home_result.source.section_path,
|
||
vec!["Home".to_string(), "Evidence Section".to_string()]
|
||
);
|
||
assert_eq!(
|
||
home_result
|
||
.source
|
||
.line_range
|
||
.as_ref()
|
||
.map(|range| range.start),
|
||
Some(4)
|
||
);
|
||
assert_eq!(
|
||
home_result.source.block_id.as_deref(),
|
||
Some("local-md:README.md#line4")
|
||
);
|
||
|
||
let scoped = query_evidence_sqlite_results(
|
||
&root,
|
||
"EvidenceToken",
|
||
Some("local-md:docs~2Fchild.md"),
|
||
10,
|
||
)
|
||
.expect("scoped sqlite query")
|
||
.expect("sqlite exists");
|
||
assert_eq!(scoped.len(), 1);
|
||
assert_eq!(scoped[0].source.owner_document_path, "docs/child.md");
|
||
|
||
let connection =
|
||
Connection::open(root.join(".mnote").join("index").join("evidence.sqlite"))
|
||
.expect("open evidence sqlite");
|
||
let section_count: i64 = connection
|
||
.query_row("SELECT COUNT(*) FROM evidence_section", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.expect("section count");
|
||
assert!(section_count >= 3);
|
||
let mention_source_block: String = connection
|
||
.query_row(
|
||
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:Reasonix'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("mention edge");
|
||
assert_eq!(mention_source_block, "local-md:README.md#line5");
|
||
let mention_locator: String = connection
|
||
.query_row(
|
||
"SELECT locator_json FROM evidence_block WHERE block_id = ?1",
|
||
rusqlite::params![mention_source_block],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("mention source locator");
|
||
assert!(mention_locator.contains("mnote.evidence_locator.v1"));
|
||
let graph_results = query_evidence_graph_results(&root, "Reasonix", None, 10)
|
||
.expect("graph query")
|
||
.expect("sqlite exists");
|
||
let mention_result = graph_results
|
||
.iter()
|
||
.find(|result| result.evidence_id.contains("mentions"))
|
||
.expect("mention graph result");
|
||
assert!(mention_result.quote.contains("mentions:"));
|
||
assert_eq!(
|
||
mention_result.source.block_id.as_deref(),
|
||
Some("local-md:README.md#line5")
|
||
);
|
||
connection
|
||
.execute("DELETE FROM evidence_edge", [])
|
||
.expect("delete graph projection");
|
||
let search_after_graph_delete =
|
||
query_evidence_sqlite_results(&root, "EvidenceToken", None, 10)
|
||
.expect("sqlite query after graph delete")
|
||
.expect("sqlite exists");
|
||
assert!(!search_after_graph_delete.is_empty());
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn evidence_sqlite_fuzzy_uses_cjk_terms_without_overrequiring_suffix() {
|
||
let root = temp_root("mnote-evidence-sqlite-cjk-fuzzy");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-evidence-cjk";
|
||
fs::write(
|
||
root.join("README.md"),
|
||
"# 有机合成\n羧酸需要被保护有许多原因,常见做法是形成稳定酯类后再脱保护。\n",
|
||
)
|
||
.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 exact = query_evidence_sqlite_results_with_mode(&root, "羧酸 保护基", None, 10, true)
|
||
.expect("exact sqlite query")
|
||
.expect("sqlite exists");
|
||
assert!(exact.is_empty(), "精确短语不应伪造不存在的同串命中");
|
||
|
||
let fuzzy = query_evidence_sqlite_results_with_mode(&root, "羧酸 保护基", None, 10, false)
|
||
.expect("fuzzy sqlite query")
|
||
.expect("sqlite exists");
|
||
assert_eq!(fuzzy.len(), 1);
|
||
let info = fuzzy[0].match_info.as_ref().expect("match info");
|
||
assert_eq!(info.match_mode, "cjk_partial");
|
||
assert!(!info.strong);
|
||
assert!(info.matched_terms.iter().any(|term| term == "羧酸"));
|
||
assert!(info.matched_terms.iter().any(|term| term == "保护"));
|
||
assert!(info.missing_terms.iter().any(|term| term == "保护基"));
|
||
assert!(fuzzy[0].quote.contains("羧酸"));
|
||
|
||
let projection = query_local_search_index(
|
||
&root,
|
||
&root_uri,
|
||
workspace_id,
|
||
"羧酸 保护基",
|
||
None,
|
||
10,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("local search projection");
|
||
assert_eq!(projection["results"].as_array().map(Vec::len), Some(1));
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn evidence_sqlite_read_context_returns_anchor_block() {
|
||
let root = temp_root("mnote-evidence-sqlite-read-context");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-evidence-read";
|
||
fs::write(
|
||
root.join("README.md"),
|
||
"# Home\nReadContextToken root body.\n",
|
||
)
|
||
.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")
|
||
.expect("sqlite exists");
|
||
let locator = results
|
||
.first()
|
||
.map(|item| item.source.clone())
|
||
.expect("locator");
|
||
|
||
let context = read_evidence_sqlite_context(&root, &locator, 1, 1)
|
||
.expect("read context")
|
||
.expect("sqlite exists");
|
||
|
||
assert_eq!(context.len(), 2);
|
||
assert!(context
|
||
.iter()
|
||
.all(|item| item.source.owner_document_path == "README.md"));
|
||
assert!(context
|
||
.iter()
|
||
.any(|item| item.quote.contains("ReadContextToken")));
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(unix)]
|
||
fn evidence_index_does_not_parse_resource_body_with_retired_liteparse_sidecar() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let _guard = env_lock().lock().expect("env lock");
|
||
let root = temp_root("mnote-evidence-resource-liteparse");
|
||
let root_uri = format!("file://{}", root.display());
|
||
let workspace_id = "local-ws-evidence-resource";
|
||
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
|
||
fs::write(
|
||
root.join("docs").join("Page.md"),
|
||
"# Page\n[Spec](./Page.assets/spec.pdf)\n",
|
||
)
|
||
.expect("write owner");
|
||
fs::write(
|
||
root.join("docs").join("Page.assets").join("spec.pdf"),
|
||
b"%PDF-1.4",
|
||
)
|
||
.expect("write pdf");
|
||
let lit = root.join("fake-lit");
|
||
fs::write(
|
||
&lit,
|
||
r#"#!/usr/bin/env bash
|
||
cat <<'JSON'
|
||
{
|
||
"version": "liteparse-index-test",
|
||
"pages": [{
|
||
"page": 2,
|
||
"width": 612,
|
||
"height": 792,
|
||
"blocks": [
|
||
{"id": "b1", "type": "heading", "level": 1, "text": "配方要求", "bbox": [72, 90, 180, 120]},
|
||
{"id": "b2", "type": "text", "text": "ResourceBodyToken 来自 PDF 正文", "bbox": [72, 130, 330, 160]}
|
||
]
|
||
}]
|
||
}
|
||
JSON
|
||
"#,
|
||
)
|
||
.expect("fake lit");
|
||
let mut permissions = fs::metadata(&lit).expect("fake lit metadata").permissions();
|
||
permissions.set_mode(0o755);
|
||
fs::set_permissions(&lit, permissions).expect("chmod fake lit");
|
||
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 {
|
||
std::env::set_var("MNOTE_LITEPARSE_BIN", value);
|
||
} else {
|
||
std::env::remove_var("MNOTE_LITEPARSE_BIN");
|
||
}
|
||
|
||
let results = query_evidence_sqlite_results(&root, "ResourceBodyToken", None, 10)
|
||
.expect("sqlite query")
|
||
.expect("sqlite exists");
|
||
assert!(
|
||
!results
|
||
.iter()
|
||
.any(|result| result.quote.contains("ResourceBodyToken")),
|
||
"LiteParse resource body fallback is retired from active evidence indexing"
|
||
);
|
||
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!(
|
||
resource_scoped.is_empty(),
|
||
"retired LiteParse sidecar must not create resource-scoped evidence hits"
|
||
);
|
||
assert!(!root
|
||
.join("docs")
|
||
.join("Page.ocr")
|
||
.join("spec.pdf.parse.md")
|
||
.exists());
|
||
assert!(!root
|
||
.join("docs")
|
||
.join("Page.ocr")
|
||
.join("spec.pdf.source-map.json")
|
||
.exists());
|
||
|
||
let _ = fs::remove_dir_all(&root);
|
||
}
|
||
}
|