kimun_notes/components/note_browser/
search_provider.rs1use std::sync::Arc;
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::{NoteEntryData, VaultPath};
6use kimun_core::note::NoteContentData;
7
8use super::format_journal_date;
9use crate::components::file_list::FileListEntry;
10use crate::components::query_vars::resolve_query;
11use crate::components::search_list::{Emit, RowSource};
12
13pub struct SearchNotesProvider {
14 vault: Arc<NoteVault>,
15 last_paths: Vec<VaultPath>,
16 current_note: Option<VaultPath>,
20}
21
22impl SearchNotesProvider {
23 pub fn new(
24 vault: Arc<NoteVault>,
25 last_paths: Vec<VaultPath>,
26 current_note: Option<VaultPath>,
27 ) -> Self {
28 Self {
29 vault,
30 last_paths,
31 current_note,
32 }
33 }
34
35 fn to_entry(&self, entry: NoteEntryData, content: NoteContentData) -> FileListEntry {
36 let filename = entry.path.get_parent_path().1;
37 let title = if content.title.trim().is_empty() {
38 "<no title>".to_string()
39 } else {
40 content.title
41 };
42 let journal_date = self
43 .vault
44 .journal_date(&entry.path)
45 .map(format_journal_date);
46 FileListEntry::Note {
47 path: entry.path,
48 title,
49 filename,
50 journal_date,
51 }
52 }
53}
54
55#[async_trait]
56impl RowSource<FileListEntry> for SearchNotesProvider {
57 async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
58 let entries: Vec<FileListEntry> = if query.is_empty() {
59 let all_notes = self.vault.get_all_notes().await.unwrap_or_default();
62 let mut by_path: std::collections::HashMap<_, _> = all_notes
63 .into_iter()
64 .map(|(entry, content)| (entry.path.clone(), (entry, content)))
65 .collect();
66
67 self.last_paths
69 .iter()
70 .filter_map(|path| by_path.remove(path))
71 .map(|(entry, content)| self.to_entry(entry, content))
72 .collect()
73 } else {
74 let resolved = resolve_query(query, self.current_note.as_ref());
79 self.vault
80 .search_notes(&resolved)
81 .await
82 .unwrap_or_default()
83 .into_iter()
84 .map(|(entry, content)| self.to_entry(entry, content))
85 .collect()
86 };
87 emit.replace(entries);
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::components::events::redraw_callback;
95 use crate::components::search_list::SearchList;
96 use crate::test_support::temp_vault;
97 use tokio::sync::mpsc::unbounded_channel;
98
99 fn has_note_named(rows: &[&FileListEntry], name: &str) -> bool {
100 rows.iter().any(|r| match r {
101 FileListEntry::Note { path, .. } => path.get_clean_name() == name,
102 _ => false,
103 })
104 }
105
106 #[tokio::test]
109 async fn resolves_note_variable_before_search() {
110 let vault = temp_vault("search_provider_note_var").await;
111 vault.validate_and_init().await.unwrap();
113 vault
114 .create_note(&VaultPath::note_path_from("spec"), "hello")
115 .await
116 .unwrap();
117
118 let (tx, _rx) = unbounded_channel();
121 let provider = SearchNotesProvider::new(
122 vault.clone(),
123 vec![],
124 Some(VaultPath::note_path_from("spec")),
125 );
126 let mut list = SearchList::builder(provider, redraw_callback(tx))
127 .initial_query("={note}")
128 .build();
129 list.poll_until_idle().await;
130 assert!(
131 has_note_named(&list.visible_rows(), "spec"),
132 "expected the 'spec' note via resolved {{note}}"
133 );
134
135 let (tx2, _rx2) = unbounded_channel();
138 let provider_none = SearchNotesProvider::new(vault.clone(), vec![], None);
139 let mut list_none = SearchList::builder(provider_none, redraw_callback(tx2))
140 .initial_query("={note}")
141 .build();
142 list_none.poll_until_idle().await;
143 assert!(
144 !has_note_named(&list_none.visible_rows(), "spec"),
145 "without an open note, {{note}} resolves to empty and must not match 'spec'"
146 );
147 }
148}