1use std::collections::HashSet;
9
10use metatheca::Uuid;
11use taxopsis::{Atom, Direction, Expr, Op, OrderBy, QueryValue};
12
13use crate::error::{Error, Result};
14use crate::snapshot::Snapshot;
15
16#[derive(Clone, Debug, Default, PartialEq)]
18pub struct FindSpec {
19 pub tags: Vec<String>,
20 pub categories: Vec<String>,
21 pub since: Option<String>,
22 pub until: Option<String>,
23 pub todo: bool,
24 pub filter: Option<String>,
25 pub raw: String,
27}
28
29impl FindSpec {
30 pub fn is_empty(&self) -> bool {
31 self.tags.is_empty()
32 && self.categories.is_empty()
33 && self.since.is_none()
34 && self.until.is_none()
35 && !self.todo
36 && self.filter.is_none()
37 }
38}
39
40fn atom(predicate: &str, op: Op, value: QueryValue) -> Expr {
41 Expr::Atom(Atom {
42 predicate: predicate.to_string(),
43 op,
44 value,
45 })
46}
47
48fn and(a: Expr, b: Expr) -> Expr {
49 Expr::And(Box::new(a), Box::new(b))
50}
51
52fn or(a: Expr, b: Expr) -> Expr {
53 Expr::Or(Box::new(a), Box::new(b))
54}
55
56fn both(member: &str, op: Op, value: &str) -> Expr {
60 or(
61 atom(
62 &format!("jot/index/{member}"),
63 op,
64 QueryValue::Str(value.to_string()),
65 ),
66 atom(
67 &format!("cuj/applied/{member}"),
68 op,
69 QueryValue::Str(value.to_string()),
70 ),
71 )
72}
73
74fn id_order() -> OrderBy {
75 OrderBy {
76 predicate: "cuj/id/id".into(),
77 direction: Direction::Asc,
78 }
79}
80
81fn taxopsis_open(app: &cuj::App) -> Result<(taxopsis::Chain, bool)> {
84 let chain = cuj::ext::open_taxopsis(app.root())?;
85 let stale = chain.current_state()?.metatheca != app.vault.current_state_hash()?;
86 Ok((chain, stale))
87}
88
89fn logopsis_open(app: &cuj::App) -> Result<(logopsis::Chain, bool)> {
90 let chain = cuj::ext::open_logopsis(app.root())?;
91 let pinned = chain.get_search_state(&chain.current_hash()?)?.metatheca;
92 let stale = pinned != app.vault.current_state_hash()?;
93 Ok((chain, stale))
94}
95
96pub fn find(app: &cuj::App, spec: &FindSpec) -> Result<(Vec<Uuid>, bool)> {
99 let (chain, stale) = taxopsis_open(app)?;
100
101 let mut expr: Option<Expr> = None;
102 let mut add = |e: Expr| {
103 expr = Some(match expr.take() {
104 Some(prev) => and(prev, e),
105 None => e,
106 });
107 };
108 for t in &spec.tags {
109 add(both("tags", Op::Eq, &t.to_lowercase()));
110 }
111 for c in &spec.categories {
112 add(both("categories", Op::Prefix, &c.to_lowercase()));
113 }
114 if let Some(s) = &spec.since {
115 let ns =
116 cuj::binding::due_to_ns(s).ok_or_else(|| Error::Usage(format!("bad date {s:?}")))?;
117 add(atom("cuj/jot/created_at_ns", Op::Ge, QueryValue::Int(ns)));
118 }
119 if let Some(u) = &spec.until {
120 let ns =
121 cuj::binding::due_to_ns(u).ok_or_else(|| Error::Usage(format!("bad date {u:?}")))?;
122 add(atom("cuj/jot/created_at_ns", Op::Le, QueryValue::Int(ns)));
123 }
124 if spec.todo {
125 add(both("todos/done", Op::Eq, "false"));
126 }
127 if let Some(f) = &spec.filter {
128 add(taxopsis::parse_filter(f)?);
129 }
130 let expr = expr.ok_or_else(|| {
131 Error::Usage(
132 "find needs at least one filter (tag, category, date, --todo, --filter)".into(),
133 )
134 })?;
135
136 let sref = chain.current_hash()?.to_hex();
137 let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
138 Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub enum SearchEngine {
144 Hybrid,
147 Bm25,
150}
151
152pub type SearchResult = (Vec<(Uuid, f64)>, bool, SearchEngine);
160
161pub fn search(
162 app: &cuj::App,
163 snap: &Snapshot,
164 query: &str,
165 k: usize,
166) -> Result<SearchResult> {
167 let want = k * 4 + 16;
169 let zchain = match zetetes::Chain::open(app.root()) {
173 Ok(chain) => {
174 let state = chain.get_state(&chain.current_hash()?)?;
175 if state.semopsis.is_none() && state.logopsis.is_none() {
176 None
177 } else {
178 Some((chain, state))
179 }
180 }
181 Err(zetetes::Error::NotFound(_)) => None,
182 Err(e) => return Err(e.into()),
183 };
184 let (raw, stale, engine) = match zchain {
185 Some((mut chain, state)) => {
186 let stale = state.metatheca != app.vault.current_state_hash()?;
187 let hits = chain.query(query, want, "current")?;
188 let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
189 (raw, stale, SearchEngine::Hybrid)
190 }
191 None => {
192 let (chain, stale) = logopsis_open(app)?;
193 let expr = logopsis::parse_query(query)?;
194 let hits = chain.query(&expr, want, "current")?;
195 let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
196 (raw, stale, SearchEngine::Bm25)
197 }
198 };
199 let mut seen = HashSet::new();
200 let mut out = Vec::new();
201 for (entry, score) in raw {
202 let Some(i) = snap.owning_row(entry) else {
203 continue;
204 };
205 let jot = snap.rows[i].entry;
206 if seen.insert(jot) {
207 out.push((jot, score));
208 if out.len() >= k {
209 break;
210 }
211 }
212 }
213 Ok((out, stale, engine))
214}
215
216pub fn backlinks(app: &cuj::App, of: Uuid) -> Result<(Vec<Uuid>, bool)> {
219 let (chain, stale) = taxopsis_open(app)?;
220 let expr = both("references/entry", Op::Eq, &of.hyphenated().to_string());
221 let sref = chain.current_hash()?.to_hex();
222 let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
223 Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
224}