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