Files
mnote/rust/crates/index-fts/src/lib.rs
T

1100 lines
35 KiB
Rust
Raw Normal View History

2026-04-15 20:01:12 +08:00
use std::collections::{HashMap, HashSet};
2026-04-14 13:22:29 +08:00
use event_log::DomainEventRecord;
2026-04-15 20:01:12 +08:00
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use serde_json::Value;
2026-04-14 13:22:29 +08:00
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexedEntityKind {
PageTitle,
PageSummary,
BlockContent,
BlockPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedDocument {
pub workspace_id: String,
pub entity_kind: IndexedEntityKind,
pub entity_id: String,
pub parent_id: Option<String>,
pub title: Option<String>,
pub content: String,
pub updated_at: String,
pub source_event_id: Option<String>,
pub revision: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexCursor {
pub workspace_id: String,
pub last_processed_event_id: String,
pub last_processed_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub workspace_id: String,
pub entity_kind: IndexedEntityKind,
pub entity_id: String,
pub snippet: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResultSet {
pub query: String,
pub workspace_id: Option<String>,
pub page_id: Option<String>,
pub hits: Vec<SearchHit>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectedDocumentBatch {
pub workspace_id: String,
pub source_event_id: String,
pub documents: Vec<IndexedDocument>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionResult {
pub cursor: IndexCursor,
pub batches: Vec<ProjectedDocumentBatch>,
}
2026-04-15 20:01:12 +08:00
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentRecord {
pub id: String,
pub workspace_id: String,
pub title: Option<String>,
pub raw_text: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchMindmapRecord {
pub document_id: String,
pub data: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchTableRecord {
pub id: String,
pub document_id: String,
pub title: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchTableRowRecord {
pub table_id: String,
pub document_id: String,
pub row_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAssetRecord {
pub id: String,
pub document_id: String,
pub asset_type: Option<String>,
pub file_name: Option<String>,
pub mime_type: Option<String>,
pub ocr_text: Option<String>,
pub ocr_status: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsDataset {
pub documents: Vec<SearchDocumentRecord>,
pub mindmaps: Vec<SearchMindmapRecord>,
pub tables: Vec<SearchTableRecord>,
pub table_rows: Vec<SearchTableRowRecord>,
pub assets: Vec<SearchAssetRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsRequest {
pub query: String,
pub workspace_id: String,
pub page_id: Option<String>,
pub limit: usize,
pub title_only: bool,
pub exact: bool,
pub include_ocr: bool,
pub time_range: String,
pub time_field: String,
pub custom_range_from: Option<String>,
pub custom_range_to: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchMatchField {
Title,
Content,
Recent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchEvidenceRecord {
pub kind: String,
pub node_id: Option<String>,
pub snippet: String,
}
2026-04-15 20:01:12 +08:00
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RankedSearchDocument {
pub id: String,
pub title: String,
pub snippet: String,
pub updated_at: Option<String>,
pub created_at: Option<String>,
pub match_field: SearchMatchField,
pub has_ocr: bool,
pub public_path: String,
pub score: f64,
pub node_id: Option<String>,
pub subtree_root_id: Option<String>,
pub evidence: Vec<SearchEvidenceRecord>,
2026-04-15 20:01:12 +08:00
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsEvaluation {
pub enqueue_asset_ids: Vec<String>,
pub results: Vec<RankedSearchDocument>,
}
2026-04-14 13:22:29 +08:00
pub trait DomainEventProjector {
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
}
pub fn supported_index_objects() -> Vec<IndexedEntityKind> {
vec![
IndexedEntityKind::PageTitle,
IndexedEntityKind::PageSummary,
IndexedEntityKind::BlockContent,
IndexedEntityKind::BlockPath,
]
}
pub fn can_rebuild_from_events(cursor: &IndexCursor) -> bool {
!cursor.workspace_id.trim().is_empty() && !cursor.last_processed_event_id.trim().is_empty()
}
pub fn advance_workspace_cursor(
cursor: &IndexCursor,
event_id: impl Into<String>,
processed_at: impl Into<String>,
) -> IndexCursor {
IndexCursor {
workspace_id: cursor.workspace_id.clone(),
last_processed_event_id: event_id.into(),
last_processed_at: processed_at.into(),
}
}
pub fn project_domain_event<P: DomainEventProjector>(
projector: &P,
event: &DomainEventRecord,
cursor: &IndexCursor,
) -> ProjectionResult {
let documents = projector.project(event);
ProjectionResult {
cursor: advance_workspace_cursor(
cursor,
event.event_id.clone(),
event.created_at.as_str().to_string(),
),
batches: if documents.is_empty() {
Vec::new()
} else {
vec![ProjectedDocumentBatch {
workspace_id: event.workspace_id.clone(),
source_event_id: event.event_id.clone(),
documents,
}]
},
}
}
pub fn rebuild_from_events<P: DomainEventProjector>(
projector: &P,
events: &[DomainEventRecord],
cursor: &IndexCursor,
) -> ProjectionResult {
let mut next_cursor = cursor.clone();
let mut batches = Vec::new();
for event in events {
if event.workspace_id != cursor.workspace_id {
continue;
}
let projected = projector.project(event);
next_cursor = advance_workspace_cursor(
&next_cursor,
event.event_id.clone(),
event.created_at.as_str().to_string(),
);
if !projected.is_empty() {
batches.push(ProjectedDocumentBatch {
workspace_id: event.workspace_id.clone(),
source_event_id: event.event_id.clone(),
documents: projected,
});
}
}
ProjectionResult {
cursor: next_cursor,
batches,
}
}
pub fn search_pages(
query: &str,
workspace_id: Option<&str>,
documents: &[IndexedDocument],
limit: usize,
) -> SearchResultSet {
let query = query.trim().to_lowercase();
let hits = documents
.iter()
2026-04-15 20:01:12 +08:00
.filter(|document| {
matches!(
document.entity_kind,
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
)
})
2026-04-14 13:22:29 +08:00
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
2026-04-15 20:01:12 +08:00
.filter(|document| {
document
.title
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&query)
|| document.content.to_lowercase().contains(&query)
})
2026-04-14 13:22:29 +08:00
.take(limit)
.map(|document| SearchHit {
workspace_id: document.workspace_id.clone(),
entity_kind: document.entity_kind.clone(),
entity_id: document.entity_id.clone(),
snippet: Some(document.content.chars().take(120).collect()),
})
.collect();
SearchResultSet {
query: query.into(),
workspace_id: workspace_id.map(|value| value.into()),
page_id: None,
hits,
}
}
pub fn search_blocks(
query: &str,
page_id: Option<&str>,
documents: &[IndexedDocument],
limit: usize,
) -> SearchResultSet {
let query = query.trim().to_lowercase();
let hits = documents
.iter()
2026-04-15 20:01:12 +08:00
.filter(|document| {
matches!(
document.entity_kind,
IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath
)
})
.filter(|document| {
page_id.map_or(true, |page| {
document.parent_id.as_deref() == Some(page) || document.entity_id == page
})
})
2026-04-14 13:22:29 +08:00
.filter(|document| document.content.to_lowercase().contains(&query))
.take(limit)
.map(|document| SearchHit {
workspace_id: document.workspace_id.clone(),
entity_kind: document.entity_kind.clone(),
entity_id: document.entity_id.clone(),
snippet: Some(document.content.chars().take(120).collect()),
})
.collect();
SearchResultSet {
query: query.into(),
workspace_id: None,
page_id: page_id.map(|value| value.into()),
hits,
}
}
2026-04-15 20:01:12 +08:00
pub fn evaluate_search_documents(
request: &SearchDocumentsRequest,
dataset: &SearchDocumentsDataset,
) -> SearchDocumentsEvaluation {
let normalized_query = request.query.trim();
if normalized_query.is_empty() {
return SearchDocumentsEvaluation {
enqueue_asset_ids: Vec::new(),
results: Vec::new(),
};
}
let normalized_lower = normalized_query.to_lowercase();
let boundary_iso = match request.time_range.as_str() {
"7d" => iso_days_ago(7),
"30d" => iso_days_ago(30),
_ => None,
};
let eligible_docs: Vec<&SearchDocumentRecord> = dataset
.documents
.iter()
.filter(|document| document.workspace_id == request.workspace_id)
.filter(|document| {
if let Some(page_id) = request.page_id.as_ref() {
document.id == *page_id
} else {
true
}
})
.filter(|document| match request.time_field.as_str() {
"created" => within_range(
document.created_at.as_deref(),
boundary_iso.as_deref(),
request.custom_range_from.as_deref(),
request.custom_range_to.as_deref(),
),
_ => within_range(
document.updated_at
.as_deref()
.or(document.created_at.as_deref()),
boundary_iso.as_deref(),
request.custom_range_from.as_deref(),
request.custom_range_to.as_deref(),
),
})
.collect();
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect();
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
.iter()
.map(|document| (document.id.as_str(), *document))
.collect();
let mut matches = HashMap::<String, SearchMatchInfo>::new();
for document in &eligible_docs {
let title = normalize_title(document.title.as_deref());
let title_lower = title.to_lowercase();
let hit_title = if request.exact {
title_lower == normalized_lower
} else {
title_lower.contains(&normalized_lower)
};
if hit_title {
upsert_match(
&mut matches,
&document.id,
SearchMatchInfo {
score: 3.0,
match_field: SearchMatchField::Title,
snippet: build_snippet(&title, normalized_query),
has_ocr: false,
},
);
}
if request.title_only {
continue;
}
let raw_text = document.raw_text.as_deref().unwrap_or("").trim();
if !raw_text.is_empty() && raw_text.to_lowercase().contains(&normalized_lower) {
upsert_match(
&mut matches,
&document.id,
SearchMatchInfo {
score: 2.0,
match_field: SearchMatchField::Content,
snippet: build_snippet(raw_text, normalized_query),
has_ocr: false,
},
);
}
}
let mut table_title_by_id = HashMap::<String, String>::new();
if !request.title_only {
for mindmap in &dataset.mindmaps {
if !eligible_doc_ids.contains(mindmap.document_id.as_str()) {
continue;
}
let text = extract_text_from_mindmap_data(&mindmap.data, 60_000);
if text.is_empty() || !text.to_lowercase().contains(&normalized_lower) {
continue;
}
upsert_match(
&mut matches,
&mindmap.document_id,
SearchMatchInfo {
score: 1.6,
match_field: SearchMatchField::Content,
snippet: build_snippet(&format!("思维导图:{text}"), normalized_query),
has_ocr: false,
},
);
}
for table in &dataset.tables {
if !eligible_doc_ids.contains(table.document_id.as_str()) {
continue;
}
let title = normalize_title(table.title.as_deref());
table_title_by_id.insert(table.id.clone(), title.clone());
if title.to_lowercase().contains(&normalized_lower) {
upsert_match(
&mut matches,
&table.document_id,
SearchMatchInfo {
score: 1.5,
match_field: SearchMatchField::Content,
snippet: build_snippet(&format!("表格:{title}"), normalized_query),
has_ocr: false,
},
);
}
}
for row in &dataset.table_rows {
if !eligible_doc_ids.contains(row.document_id.as_str()) {
continue;
}
let row_hash = row.row_hash.as_deref().unwrap_or("").trim();
if row_hash.is_empty() || !row_hash.to_lowercase().contains(&normalized_lower) {
continue;
}
let table_title = table_title_by_id
.get(&row.table_id)
.cloned()
.unwrap_or_else(|| "未命名表格".into());
upsert_match(
&mut matches,
&row.document_id,
SearchMatchInfo {
score: 1.4,
match_field: SearchMatchField::Content,
snippet: build_snippet(
&format!("表格:{table_title}\n{row_hash}"),
normalized_query,
),
has_ocr: false,
},
);
}
}
let mut enqueue_asset_ids = Vec::new();
for asset in &dataset.assets {
if !eligible_doc_ids.contains(asset.document_id.as_str()) {
continue;
}
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) {
upsert_match(
&mut matches,
&asset.document_id,
SearchMatchInfo {
score: 1.2,
match_field: SearchMatchField::Content,
snippet: build_snippet(&format!("附件:{file_name}"), normalized_query),
has_ocr: false,
},
);
}
if !request.include_ocr {
continue;
}
let ocr_text = asset.ocr_text.as_deref().unwrap_or("").trim();
if ocr_text.is_empty() {
let ocr_status = asset.ocr_status.as_deref().unwrap_or("").trim();
let busy = ocr_status == "queued" || ocr_status == "running";
if !busy
&& should_extract_attachment_text(
asset.asset_type.as_deref(),
asset.mime_type.as_deref(),
asset.file_name.as_deref(),
)
{
enqueue_asset_ids.push(asset.id.clone());
}
continue;
}
if ocr_text.to_lowercase().contains(&normalized_lower) {
upsert_match(
&mut matches,
&asset.document_id,
SearchMatchInfo {
score: 1.7,
match_field: SearchMatchField::Content,
snippet: build_snippet(
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }),
normalized_query,
),
has_ocr: true,
},
);
}
}
let mut results = matches
.into_iter()
.filter_map(|(document_id, matched)| {
let document = doc_map.get(document_id.as_str())?;
let snippet = matched.snippet.clone();
let evidence_kind = if matched.has_ocr {
"ocr".to_string()
} else {
match matched.match_field {
SearchMatchField::Title => "title".to_string(),
SearchMatchField::Content => "content".to_string(),
SearchMatchField::Recent => "recent".to_string(),
}
};
2026-04-15 20:01:12 +08:00
Some(RankedSearchDocument {
id: document.id.clone(),
title: normalize_title(document.title.as_deref()),
snippet: snippet.clone(),
2026-04-15 20:01:12 +08:00
updated_at: document.updated_at.clone(),
created_at: document.created_at.clone(),
match_field: matched.match_field,
has_ocr: matched.has_ocr,
public_path: format!("/documents/{}", document.id),
score: matched.score,
node_id: Some(document.id.clone()),
subtree_root_id: Some(document.id.clone()),
evidence: vec![SearchEvidenceRecord {
kind: evidence_kind,
node_id: Some(document.id.clone()),
snippet,
}],
2026-04-15 20:01:12 +08:00
})
})
.collect::<Vec<_>>();
results.sort_by(|left, right| {
right
.score
.partial_cmp(&left.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
let left_time = left
.updated_at
.as_deref()
.or(left.created_at.as_deref())
.unwrap_or("");
let right_time = right
.updated_at
.as_deref()
.or(right.created_at.as_deref())
.unwrap_or("");
right_time.cmp(left_time)
})
.then_with(|| left.title.cmp(&right.title))
});
results.truncate(request.limit);
enqueue_asset_ids.sort();
enqueue_asset_ids.dedup();
if enqueue_asset_ids.len() > 3 {
enqueue_asset_ids.truncate(3);
}
SearchDocumentsEvaluation {
enqueue_asset_ids,
results,
}
}
2026-04-14 13:22:29 +08:00
pub struct MinimalWorkspaceProjector;
impl DomainEventProjector for MinimalWorkspaceProjector {
fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument> {
let content = event.payload_json.trim();
let (entity_kind, title, parent_id) = match event.aggregate_type.as_str() {
"page" => (
IndexedEntityKind::PageTitle,
Some(event.event_type.clone()),
None,
),
"block" => (
IndexedEntityKind::BlockContent,
None,
Some(event.aggregate_id.clone()),
),
_ => return Vec::new(),
};
vec![IndexedDocument {
workspace_id: event.workspace_id.clone(),
entity_kind,
entity_id: event.aggregate_id.clone(),
parent_id,
title,
content: content.to_string(),
updated_at: event.created_at.as_str().to_string(),
source_event_id: Some(event.event_id.clone()),
revision: Some(event.event_version as u64),
}]
}
}
2026-04-15 20:01:12 +08:00
#[derive(Debug, Clone)]
struct SearchMatchInfo {
score: f64,
match_field: SearchMatchField,
snippet: String,
has_ocr: bool,
}
fn normalize_title(title: Option<&str>) -> String {
let normalized = title.unwrap_or("").trim();
if normalized.is_empty() {
"无标题".into()
} else {
normalized.into()
}
}
fn within_range(
timestamp: Option<&str>,
boundary_iso: Option<&str>,
custom_range_from: Option<&str>,
custom_range_to: Option<&str>,
) -> bool {
let Some(timestamp) = timestamp else {
return boundary_iso.is_none() && custom_range_from.is_none() && custom_range_to.is_none();
};
if let Some(boundary_iso) = boundary_iso {
if timestamp < boundary_iso {
return false;
}
}
if let Some(custom_range_from) = custom_range_from {
if timestamp < custom_range_from {
return false;
}
}
if let Some(custom_range_to) = custom_range_to {
if timestamp > custom_range_to {
return false;
}
}
true
}
fn iso_days_ago(days: i64) -> Option<String> {
use std::time::{Duration, SystemTime};
let seconds = days.checked_mul(24 * 60 * 60)?;
let duration = Duration::from_secs(seconds as u64);
let cutoff = SystemTime::now().checked_sub(duration)?;
let datetime = chrono_like::system_time_to_iso(cutoff)?;
Some(datetime)
}
fn upsert_match(
matches: &mut HashMap<String, SearchMatchInfo>,
document_id: &str,
patch: SearchMatchInfo,
) {
let Some(previous) = matches.get(document_id).cloned() else {
matches.insert(document_id.into(), patch);
return;
};
let mut next = SearchMatchInfo {
score: previous.score.max(patch.score),
match_field: patch.match_field.clone(),
snippet: patch.snippet.clone(),
has_ocr: previous.has_ocr || patch.has_ocr,
};
if matches!(previous.match_field, SearchMatchField::Title)
&& !matches!(patch.match_field, SearchMatchField::Title)
{
next.match_field = SearchMatchField::Title;
next.snippet = previous.snippet;
} else if patch.score <= previous.score {
next.match_field = previous.match_field;
next.snippet = previous.snippet;
}
matches.insert(document_id.into(), next);
}
fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
if output.join("\n").len() >= max_chars {
return;
}
match node {
Value::Object(map) => {
if let Some(Value::Object(data)) = map.get("data") {
if let Some(Value::String(text)) = data.get("text") {
let normalized = normalize_text(text);
if !normalized.is_empty() {
output.push(normalized);
}
}
}
if let Some(Value::Array(children)) = map.get("children") {
for child in children {
walk(child, output, max_chars);
if output.join("\n").len() >= max_chars {
break;
}
}
}
if let Some(root) = map.get("root") {
walk(root, output, max_chars);
}
}
Value::Array(items) => {
for item in items {
walk(item, output, max_chars);
if output.join("\n").len() >= max_chars {
break;
}
}
}
_ => {}
}
}
let mut output = Vec::new();
walk(value, &mut output, max_chars);
let joined = output.join("\n").trim().to_string();
if joined.len() > max_chars {
format!("{}…", &joined[..max_chars])
} else {
joined
}
}
fn normalize_text(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn build_snippet(text: &str, keyword: &str) -> String {
let source = text.trim();
if source.is_empty() {
return "暂无正文内容".into();
}
let escaped = escape_html(source);
if keyword.trim().is_empty() {
return truncate_snippet(&escaped);
}
let regex = match RegexBuilder::new(&regex::escape(keyword))
.case_insensitive(true)
.build()
{
Ok(regex) => regex,
Err(_) => return truncate_snippet(&escaped),
};
let Some(found) = regex.find(&escaped) else {
return truncate_snippet(&escaped);
};
let start = found.start().saturating_sub(20);
let end = (found.end() + 80).min(escaped.len());
let segment = escaped[start..end].to_string();
regex
.replace_all(&segment, |captures: &regex::Captures<'_>| {
format!("<mark>{}</mark>", &captures[0])
})
.to_string()
}
fn truncate_snippet(value: &str) -> String {
let mut chars = value.chars();
let truncated = chars.by_ref().take(120).collect::<String>();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
fn should_extract_attachment_text(
asset_type: Option<&str>,
mime_type: Option<&str>,
file_name: Option<&str>,
) -> bool {
if asset_type.unwrap_or("").trim() != "file" {
return false;
}
let mime_type = mime_type.unwrap_or("").trim().to_lowercase();
if matches!(
mime_type.as_str(),
"application/pdf"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) {
return true;
}
let file_name = file_name.unwrap_or("").trim().to_lowercase();
[".pdf", ".docx", ".pptx", ".xlsx"]
.iter()
.any(|suffix| file_name.ends_with(suffix))
}
mod chrono_like {
use std::time::{SystemTime, UNIX_EPOCH};
pub fn system_time_to_iso(time: SystemTime) -> Option<String> {
let duration = time.duration_since(UNIX_EPOCH).ok()?;
let seconds = duration.as_secs() as i64;
let days = seconds.div_euclid(86_400);
let secs_of_day = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days)?;
let hour = secs_of_day / 3_600;
let minute = (secs_of_day % 3_600) / 60;
let second = secs_of_day % 60;
Some(format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
))
}
fn civil_from_days(days: i64) -> Option<(i64, i64, i64)> {
let z = days.checked_add(719_468)?;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = mp + if mp < 10 { 3 } else { -9 };
let year = y + if m <= 2 { 1 } else { 0 };
Some((year, m, d))
}
}
2026-04-14 13:22:29 +08:00
#[cfg(test)]
mod tests {
use super::*;
use core_domain::Timestamp;
use event_log::EventStatus;
2026-04-15 20:01:12 +08:00
use serde_json::json;
2026-04-14 13:22:29 +08:00
fn cursor() -> IndexCursor {
IndexCursor {
workspace_id: "ws_1".into(),
last_processed_event_id: "evt_0".into(),
last_processed_at: "2026-04-11T00:00:00Z".into(),
}
}
fn event(
workspace_id: &str,
aggregate_type: &str,
aggregate_id: &str,
event_id: &str,
event_type: &str,
) -> DomainEventRecord {
DomainEventRecord {
event_id: event_id.into(),
workspace_id: workspace_id.into(),
aggregate_type: aggregate_type.into(),
aggregate_id: aggregate_id.into(),
event_type: event_type.into(),
event_version: 1,
payload_json: "{\"text\":\"hello\"}".into(),
command_log_id: "cmd_1".into(),
actor_type: "human".into(),
created_at: Timestamp::new("2026-04-11T00:00:01Z"),
status: EventStatus::Committed,
trace_id: "trace_1".into(),
request_id: "req_1".into(),
command_id: "cmd_1".into(),
}
}
#[test]
fn supports_page_and_block_projection_objects() {
assert_eq!(
supported_index_objects(),
vec![
IndexedEntityKind::PageTitle,
IndexedEntityKind::PageSummary,
IndexedEntityKind::BlockContent,
IndexedEntityKind::BlockPath
]
);
}
#[test]
fn cursor_advances_incrementally() {
let next = advance_workspace_cursor(&cursor(), "evt_2", "2026-04-11T00:00:02Z");
assert_eq!(next.last_processed_event_id, "evt_2");
assert_eq!(next.workspace_id, "ws_1");
}
#[test]
fn projector_builds_real_documents_for_page_and_block() {
let projector = MinimalWorkspaceProjector;
let page_docs =
projector.project(&event("ws_1", "page", "page_1", "evt_1", "page.created"));
let block_docs =
projector.project(&event("ws_1", "block", "block_1", "evt_2", "block.created"));
assert_eq!(page_docs.len(), 1);
assert_eq!(block_docs.len(), 1);
assert_eq!(page_docs[0].entity_kind, IndexedEntityKind::PageTitle);
assert_eq!(block_docs[0].entity_kind, IndexedEntityKind::BlockContent);
}
#[test]
fn rebuild_skips_other_workspaces_and_keeps_cursor() {
let projector = MinimalWorkspaceProjector;
let events = vec![
event("ws_2", "page", "page_x", "evt_x", "page.created"),
event("ws_1", "block", "block_1", "evt_1", "block.created"),
];
let result = rebuild_from_events(&projector, &events, &cursor());
assert_eq!(result.batches.len(), 1);
assert_eq!(result.cursor.last_processed_event_id, "evt_1");
}
#[test]
fn search_helpers_return_hits() {
let docs = vec![
IndexedDocument {
workspace_id: "ws_1".into(),
entity_kind: IndexedEntityKind::PageTitle,
entity_id: "page_1".into(),
parent_id: None,
title: Some("Rust Notes".into()),
content: "Rust notes for phase 2".into(),
updated_at: "2026-04-11T00:00:01Z".into(),
source_event_id: Some("evt_1".into()),
revision: Some(1),
},
IndexedDocument {
workspace_id: "ws_1".into(),
entity_kind: IndexedEntityKind::BlockContent,
entity_id: "block_1".into(),
parent_id: Some("page_1".into()),
title: None,
content: "A block about rust search indexing".into(),
updated_at: "2026-04-11T00:00:02Z".into(),
source_event_id: Some("evt_2".into()),
revision: Some(1),
},
];
let pages = search_pages("rust", Some("ws_1"), &docs, 10);
assert_eq!(pages.hits.len(), 1);
assert_eq!(pages.hits[0].entity_id, "page_1");
let blocks = search_blocks("search", Some("page_1"), &docs, 10);
assert_eq!(blocks.hits.len(), 1);
assert_eq!(blocks.hits[0].entity_id, "block_1");
}
2026-04-15 20:01:12 +08:00
#[test]
fn evaluate_search_documents_uses_rust_scoring_and_snippet() {
let result = evaluate_search_documents(
&SearchDocumentsRequest {
query: "rust".into(),
workspace_id: "ws_1".into(),
page_id: None,
limit: 10,
title_only: false,
exact: false,
include_ocr: true,
time_range: "any".into(),
time_field: "updated".into(),
custom_range_from: None,
custom_range_to: None,
},
&SearchDocumentsDataset {
documents: vec![
SearchDocumentRecord {
id: "page_1".into(),
workspace_id: "ws_1".into(),
title: Some("Rust Notes".into()),
raw_text: Some("正文包含 rust 搜索".into()),
created_at: Some("2026-04-11T00:00:01Z".into()),
updated_at: Some("2026-04-11T00:00:02Z".into()),
},
SearchDocumentRecord {
id: "page_2".into(),
workspace_id: "ws_1".into(),
title: Some("附件页".into()),
raw_text: Some("".into()),
created_at: Some("2026-04-11T00:00:01Z".into()),
updated_at: Some("2026-04-11T00:00:03Z".into()),
},
],
mindmaps: vec![SearchMindmapRecord {
document_id: "page_1".into(),
data: json!({
"root": {
"data": { "text": "Rust 脑图节点" },
"children": [],
}
}),
}],
tables: vec![],
table_rows: vec![],
assets: vec![SearchAssetRecord {
id: "asset_1".into(),
document_id: "page_2".into(),
asset_type: Some("file".into()),
file_name: Some("demo.pdf".into()),
mime_type: Some("application/pdf".into()),
ocr_text: None,
ocr_status: Some("idle".into()),
}],
},
);
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].id, "page_1");
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].evidence.len(), 1);
2026-04-15 20:01:12 +08:00
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
}
2026-04-14 13:22:29 +08:00
}