feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
446
product-source/hololake-platform/src-tauri/src/search.rs
Normal file
446
product-source/hololake-platform/src-tauri/src/search.rs
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct SearchResult {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
pub note_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<SearchResult>,
|
||||
pub elapsed_ms: u64,
|
||||
pub query: String,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
pub struct SearchOptions<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub query: &'a str,
|
||||
pub mode: &'a str,
|
||||
pub limit: usize,
|
||||
pub hide_gitignored_files: bool,
|
||||
pub exclude_frontmatter: bool,
|
||||
}
|
||||
|
||||
struct Utf8Boundary<'a> {
|
||||
text: &'a str,
|
||||
}
|
||||
|
||||
struct SnippetRequest<'a> {
|
||||
content: &'a str,
|
||||
query_lower: &'a str,
|
||||
}
|
||||
|
||||
struct MatchScoreRequest<'a> {
|
||||
title_lower: &'a str,
|
||||
content_lower: &'a str,
|
||||
query_lower: &'a str,
|
||||
}
|
||||
|
||||
impl Utf8Boundary<'_> {
|
||||
fn floor(&self, index: usize) -> usize {
|
||||
let mut boundary = index.min(self.text.len());
|
||||
while boundary > 0 && !self.text.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
boundary
|
||||
}
|
||||
|
||||
fn lower_to_source(&self, lower_index: usize) -> usize {
|
||||
let mut lowered_len = 0;
|
||||
for (source_index, ch) in self.text.char_indices() {
|
||||
if lowered_len >= lower_index {
|
||||
return source_index;
|
||||
}
|
||||
lowered_len += ch.to_lowercase().map(|c| c.len_utf8()).sum::<usize>();
|
||||
if lowered_len > lower_index {
|
||||
return source_index;
|
||||
}
|
||||
}
|
||||
self.text.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl SnippetRequest<'_> {
|
||||
fn extract(&self) -> String {
|
||||
let content_lower = self.content.to_lowercase();
|
||||
let lower_pos = match content_lower.find(self.query_lower) {
|
||||
Some(p) => p,
|
||||
None => return String::new(),
|
||||
};
|
||||
let content_boundary = Utf8Boundary { text: self.content };
|
||||
let pos = content_boundary.lower_to_source(lower_pos);
|
||||
let start = self.content[..pos]
|
||||
.rfind('\n')
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or_else(|| content_boundary.floor(pos.saturating_sub(60)));
|
||||
let end = self.content[pos..]
|
||||
.find('\n')
|
||||
.map(|i| pos + i)
|
||||
.unwrap_or_else(|| content_boundary.floor((pos + 120).min(self.content.len())));
|
||||
let snippet = &self.content[start..end];
|
||||
if snippet.len() > 200 {
|
||||
let end = Utf8Boundary { text: snippet }.floor(200);
|
||||
format!("{}…", &snippet[..end])
|
||||
} else {
|
||||
snippet.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MatchScoreRequest<'_> {
|
||||
fn score(&self) -> f64 {
|
||||
let title_exact = self.title_lower.contains(self.query_lower);
|
||||
let title_word = self
|
||||
.title_lower
|
||||
.split_whitespace()
|
||||
.any(|word| word == self.query_lower);
|
||||
let content_count = self.content_lower.matches(self.query_lower).count();
|
||||
|
||||
let mut score = 0.0;
|
||||
if title_word {
|
||||
score += 10.0;
|
||||
} else if title_exact {
|
||||
score += 5.0;
|
||||
}
|
||||
score += (content_count as f64).min(20.0) * 0.5;
|
||||
score
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_vault(
|
||||
vault_path: &str,
|
||||
query: &str,
|
||||
_mode: &str,
|
||||
limit: usize,
|
||||
) -> Result<SearchResponse, String> {
|
||||
search_vault_with_options(SearchOptions {
|
||||
vault_path,
|
||||
query,
|
||||
mode: _mode,
|
||||
limit,
|
||||
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
|
||||
exclude_frontmatter: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_frontmatter(content: &str) -> &str {
|
||||
let Some(rest) = content.strip_prefix("---") else {
|
||||
return content;
|
||||
};
|
||||
|
||||
match rest.find("\n---") {
|
||||
Some(end) => rest[end + 4..].trim_start(),
|
||||
None => content,
|
||||
}
|
||||
}
|
||||
|
||||
fn searchable_content(content: &str, exclude_frontmatter: bool) -> &str {
|
||||
if exclude_frontmatter {
|
||||
strip_frontmatter(content)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
fn is_markdown_search_candidate(vault_dir: &Path, path: &Path) -> bool {
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let vault_relative_path = path.strip_prefix(vault_dir).unwrap_or(path);
|
||||
!vault_relative_path
|
||||
.components()
|
||||
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|
||||
}
|
||||
|
||||
fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec<PathBuf> {
|
||||
let paths = WalkDir::new(vault_dir)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.into_path())
|
||||
.filter(|path| is_markdown_search_candidate(vault_dir, path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files)
|
||||
}
|
||||
|
||||
pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchResponse, String> {
|
||||
let start = Instant::now();
|
||||
let query_lower = options.query.to_lowercase();
|
||||
let vault_dir = Path::new(options.vault_path);
|
||||
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
|
||||
for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let searchable_content = searchable_content(&content, options.exclude_frontmatter);
|
||||
let content_lower = searchable_content.to_lowercase();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("");
|
||||
let title = crate::vault::derive_markdown_title_from_content(&content, filename);
|
||||
let title_lower = title.to_lowercase();
|
||||
|
||||
if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let score = MatchScoreRequest {
|
||||
title_lower: &title_lower,
|
||||
content_lower: &content_lower,
|
||||
query_lower: &query_lower,
|
||||
}
|
||||
.score();
|
||||
let snippet = SnippetRequest {
|
||||
content: searchable_content,
|
||||
query_lower: &query_lower,
|
||||
}
|
||||
.extract();
|
||||
let full_path = path.to_string_lossy().to_string();
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
path: full_path,
|
||||
snippet,
|
||||
score,
|
||||
note_type: None,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
results.truncate(options.limit);
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(SearchResponse {
|
||||
results,
|
||||
elapsed_ms,
|
||||
query: options.query.to_string(),
|
||||
mode: options.mode.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::Builder;
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
crate::hidden_command("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
macro_rules! snippet {
|
||||
($content:expr, $query_lower:expr) => {
|
||||
SnippetRequest {
|
||||
content: $content,
|
||||
query_lower: $query_lower,
|
||||
}
|
||||
.extract()
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! match_score {
|
||||
($title_lower:expr, $content_lower:expr, $query_lower:expr) => {
|
||||
MatchScoreRequest {
|
||||
title_lower: $title_lower,
|
||||
content_lower: $content_lower,
|
||||
query_lower: $query_lower,
|
||||
}
|
||||
.score()
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_basic() {
|
||||
let content = "line one\nline with keyword here\nline three";
|
||||
let snippet = snippet!(content, "keyword");
|
||||
assert!(snippet.contains("keyword"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_no_match() {
|
||||
let snippet = snippet!("nothing here", "missing");
|
||||
assert!(snippet.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_match_title_word() {
|
||||
let score = match_score!("my keyword", "", "keyword");
|
||||
assert!(score >= 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_match_content_only() {
|
||||
let score = match_score!("unrelated", "some keyword text keyword", "keyword");
|
||||
assert!(score > 0.0);
|
||||
assert!(score < 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_long() {
|
||||
let long_line = "a".repeat(300);
|
||||
let content = format!("start\n{}keyword{}\nend", long_line, long_line);
|
||||
let snippet = snippet!(&content, "keyword");
|
||||
assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_multibyte_context_start() {
|
||||
let prefix = format!("{}a", "한".repeat(21));
|
||||
let content = format!("{prefix}needle after multibyte prefix");
|
||||
|
||||
let snippet = snippet!(&content, "needle");
|
||||
|
||||
assert!(snippet.contains("needle"));
|
||||
assert!(snippet.is_char_boundary(snippet.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_multibyte_context_end() {
|
||||
let content = format!("x{}", "한".repeat(50));
|
||||
|
||||
let snippet = snippet!(&content, "x");
|
||||
|
||||
assert!(snippet.starts_with('x'));
|
||||
assert!(snippet.is_char_boundary(snippet.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_multibyte_truncation() {
|
||||
let content = format!("key {}\n", "한".repeat(100));
|
||||
|
||||
let snippet = snippet!(&content, "key");
|
||||
|
||||
assert!(snippet.starts_with("key"));
|
||||
assert!(snippet.ends_with('…'));
|
||||
assert!(snippet.is_char_boundary(snippet.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_snippet_maps_expanded_lowercase_to_source_boundary() {
|
||||
let content = "İstanbul needle";
|
||||
|
||||
let snippet = snippet!(content, "i");
|
||||
|
||||
assert!(snippet.starts_with("İstanbul"));
|
||||
assert!(snippet.is_char_boundary(snippet.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_vault_uses_h1_for_result_title() {
|
||||
let dir = Builder::new()
|
||||
.prefix("search-vault-")
|
||||
.tempdir_in(std::env::current_dir().unwrap())
|
||||
.unwrap();
|
||||
let note_path = dir.path().join("legacy-name.md");
|
||||
fs::write(
|
||||
¬e_path,
|
||||
"# Updated Display Title\n\nThe body contains keyword for search.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response =
|
||||
search_vault(dir.path().to_str().unwrap(), "keyword", "keyword", 10).unwrap();
|
||||
|
||||
assert_eq!(response.results.len(), 1);
|
||||
assert_eq!(response.results[0].title, "Updated Display Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_vault_hides_gitignored_notes_when_enabled() {
|
||||
let dir = Builder::new()
|
||||
.prefix("search-gitignored-")
|
||||
.tempdir_in(std::env::current_dir().unwrap())
|
||||
.unwrap();
|
||||
init_git_repo(dir.path());
|
||||
fs::create_dir_all(dir.path().join("ignored")).unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
|
||||
fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap();
|
||||
fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap();
|
||||
|
||||
let hidden = search_vault_with_options(SearchOptions {
|
||||
vault_path: dir.path().to_str().unwrap(),
|
||||
query: "needle",
|
||||
mode: "keyword",
|
||||
limit: 10,
|
||||
hide_gitignored_files: true,
|
||||
exclude_frontmatter: false,
|
||||
})
|
||||
.unwrap();
|
||||
let shown = search_vault_with_options(SearchOptions {
|
||||
vault_path: dir.path().to_str().unwrap(),
|
||||
query: "needle",
|
||||
mode: "keyword",
|
||||
limit: 10,
|
||||
hide_gitignored_files: false,
|
||||
exclude_frontmatter: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(hidden.results.len(), 1);
|
||||
assert_eq!(hidden.results[0].title, "Visible");
|
||||
assert_eq!(shown.results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_vault_can_exclude_frontmatter_from_content_matches() {
|
||||
let dir = Builder::new()
|
||||
.prefix("search-frontmatter-scope-")
|
||||
.tempdir_in(std::env::current_dir().unwrap())
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("frontmatter-only.md"),
|
||||
[
|
||||
"---",
|
||||
"Owner: hidden-frontmatter-keyword",
|
||||
"---",
|
||||
"",
|
||||
"# Public Body",
|
||||
"",
|
||||
"The note body deliberately omits the hidden property token.",
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("body-match.md"),
|
||||
"# Body Match\n\nBody includes hidden-frontmatter-keyword here.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = search_vault_with_options(SearchOptions {
|
||||
vault_path: dir.path().to_str().unwrap(),
|
||||
query: "hidden-frontmatter-keyword",
|
||||
mode: "keyword",
|
||||
limit: 10,
|
||||
hide_gitignored_files: false,
|
||||
exclude_frontmatter: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.results.len(), 1);
|
||||
assert_eq!(response.results[0].title, "Body Match");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue