Skip to main content

bear_rs/
search.rs

1//! Bear search-query parser. Converts a query string into a SQL WHERE
2//! clause fragment and bound parameters.
3//!
4//! Supported tokens:
5//!   bare term          -> ZTEXT/ZTITLE LIKE '%term%'
6//!   "exact phrase"     -> ZTEXT/ZTITLE LIKE '%exact phrase%'
7//!   -negation          -> NOT (ZTEXT/ZTITLE LIKE '%negation%')
8//!   #tag               -> note has tag
9//!   !#tag              -> note has tag (exact, alias)
10//!   @today             -> modified today (local midnight)
11//!   @yesterday         -> modified yesterday
12//!   @lastNdays         -> modified in last N days
13//!   @date(YYYY-MM-DD)  -> modified on that date
14//!   @ctoday            -> created today
15//!   @createdNdays      -> created in last N days
16//!   @cdate(YYYY-MM-DD) -> created on that date
17//!   @todo              -> ZTODOINCOMPLETED > 0
18//!   @done              -> ZTODOCOMPLETED > 0
19//!   @task              -> alias for @todo
20//!   @tagged            -> has at least one tag
21//!   @untagged          -> has no tags
22//!   @pinned            -> ZPINNED = 1
23//!   @images            -> ZHASIMAGES = 1
24//!   @files             -> ZHASFILES = 1
25//!   @attachments       -> ZHASIMAGES = 1 OR ZHASFILES = 1
26//!   @code              -> ZHASSOURCECODE = 1
27//!   @locked            -> ZLOCKED = 1
28//!   @title <term>      -> next bare term matched against ZTITLE only
29//!   @untitled          -> ZTITLE IS NULL OR ZTITLE = ''
30//!   @empty             -> ZTEXT IS NULL OR ZTEXT = ''
31//!
32//! Unsupported tokens (@ocr, @wikilinks, @backlinks, @readonly) are
33//! silently skipped with a warning to stderr.
34
35use chrono::{Duration, Local, NaiveDate, TimeZone};
36
37/// Result of parsing a Bear query string.
38pub struct ParsedQuery {
39    /// SQL fragments joined with AND, ready to embed in a WHERE clause.
40    /// Each `?` placeholder corresponds to an entry in `params`.
41    pub clauses: Vec<String>,
42    /// Bound parameter values (all strings for rusqlite).
43    pub params: Vec<String>,
44    /// Extra JOIN clauses required (e.g. for tag filters).
45    pub joins: Vec<String>,
46}
47
48impl ParsedQuery {
49    fn new() -> Self {
50        ParsedQuery {
51            clauses: Vec::new(),
52            params: Vec::new(),
53            joins: Vec::new(),
54        }
55    }
56
57    fn push_like(&mut self, col: &str, value: &str) {
58        self.clauses.push(format!("{col} LIKE ? ESCAPE '\\'"));
59        self.params.push(format!("%{}%", escape_like(value)));
60    }
61
62    fn push_not_like(&mut self, value: &str) {
63        self.clauses.push(
64            "(n.ZTEXT NOT LIKE ? ESCAPE '\\' AND n.ZTITLE NOT LIKE ? ESCAPE '\\')".to_string(),
65        );
66        let pat = format!("%{}%", escape_like(value));
67        self.params.push(pat.clone());
68        self.params.push(pat);
69    }
70
71    fn push_text_or_title_like(&mut self, value: &str) {
72        self.clauses
73            .push("(n.ZTEXT LIKE ? ESCAPE '\\' OR n.ZTITLE LIKE ? ESCAPE '\\')".to_string());
74        let pat = format!("%{}%", escape_like(value));
75        self.params.push(pat.clone());
76        self.params.push(pat);
77    }
78
79    fn push_tag(&mut self, tag: &str) {
80        let alias = format!("tag_{}", self.joins.len());
81        self.joins.push(format!(
82            "JOIN Z_5TAGS {a}t ON {a}t.Z_5NOTES = n.Z_PK \
83             JOIN ZSFNOTETAG {a}n ON {a}n.Z_PK = {a}t.Z_13TAGS AND {a}n.ZTITLE = ?",
84            a = alias
85        ));
86        self.params.push(tag.to_string());
87    }
88}
89
90fn escape_like(s: &str) -> String {
91    s.replace('\\', "\\\\")
92        .replace('%', "\\%")
93        .replace('_', "\\_")
94}
95
96/// Parse a Bear query string into SQL fragments.
97pub fn parse_query(query: &str) -> ParsedQuery {
98    let mut pq = ParsedQuery::new();
99    let mut chars = query.chars().peekable();
100    let mut title_only = false;
101
102    while chars.peek().is_some() {
103        // skip leading whitespace
104        while chars.peek().map(|c| c.is_whitespace()) == Some(true) {
105            chars.next();
106        }
107        if chars.peek().is_none() {
108            break;
109        }
110
111        let ch = *chars.peek().unwrap();
112
113        if ch == '"' {
114            // quoted phrase
115            chars.next();
116            let phrase: String = chars.by_ref().take_while(|&c| c != '"').collect();
117            if !phrase.is_empty() {
118                if title_only {
119                    pq.push_like("n.ZTITLE", &phrase);
120                    title_only = false;
121                } else {
122                    pq.push_text_or_title_like(&phrase);
123                }
124            }
125        } else if ch == '-' {
126            // negation: -term or -"phrase"
127            chars.next();
128            let term = read_token(&mut chars);
129            let term = term.trim_matches('"');
130            if !term.is_empty() {
131                pq.push_not_like(term);
132            }
133        } else if ch == '#' || (ch == '!' && chars.clone().nth(1) == Some('#')) {
134            // tag filter: #tag or !#tag
135            if ch == '!' {
136                chars.next(); // consume '!'
137            }
138            chars.next(); // consume '#'
139            let tag: String = chars.by_ref().take_while(|&c| !c.is_whitespace()).collect();
140            if !tag.is_empty() {
141                pq.push_tag(&tag);
142            }
143        } else if ch == '@' {
144            chars.next(); // consume '@'
145            let token: String = chars.by_ref().take_while(|&c| !c.is_whitespace()).collect();
146            match token.to_lowercase().as_str() {
147                "today" => {
148                    let ts = local_midnight_coredata(0);
149                    pq.clauses.push("n.ZMODIFICATIONDATE >= ?".to_string());
150                    pq.params.push(ts.to_string());
151                }
152                "yesterday" => {
153                    let start = local_midnight_coredata(-1);
154                    let end = local_midnight_coredata(0);
155                    pq.clauses
156                        .push("(n.ZMODIFICATIONDATE >= ? AND n.ZMODIFICATIONDATE < ?)".to_string());
157                    pq.params.push(start.to_string());
158                    pq.params.push(end.to_string());
159                }
160                "ctoday" => {
161                    let ts = local_midnight_coredata(0);
162                    pq.clauses.push("n.ZCREATIONDATE >= ?".to_string());
163                    pq.params.push(ts.to_string());
164                }
165                "untitled" => {
166                    pq.clauses
167                        .push("(n.ZTITLE IS NULL OR n.ZTITLE = '')".to_string());
168                }
169                "empty" => {
170                    pq.clauses
171                        .push("(n.ZTEXT IS NULL OR n.ZTEXT = '')".to_string());
172                }
173                "todo" | "task" => {
174                    pq.clauses.push("n.ZTODOINCOMPLETED > 0".to_string());
175                }
176                "done" => {
177                    pq.clauses.push("n.ZTODOCOMPLETED > 0".to_string());
178                }
179                "tagged" => {
180                    pq.clauses
181                        .push("EXISTS (SELECT 1 FROM Z_5TAGS WHERE Z_5NOTES = n.Z_PK)".to_string());
182                }
183                "untagged" => {
184                    pq.clauses.push(
185                        "NOT EXISTS (SELECT 1 FROM Z_5TAGS WHERE Z_5NOTES = n.Z_PK)".to_string(),
186                    );
187                }
188                "pinned" => {
189                    pq.clauses.push("n.ZPINNED = 1".to_string());
190                }
191                "images" => {
192                    pq.clauses.push("n.ZHASIMAGES = 1".to_string());
193                }
194                "files" => {
195                    pq.clauses.push("n.ZHASFILES = 1".to_string());
196                }
197                "attachments" => {
198                    pq.clauses
199                        .push("(n.ZHASIMAGES = 1 OR n.ZHASFILES = 1)".to_string());
200                }
201                "code" => {
202                    pq.clauses.push("n.ZHASSOURCECODE = 1".to_string());
203                }
204                "locked" => {
205                    pq.clauses.push("n.ZLOCKED = 1".to_string());
206                }
207                "title" => {
208                    title_only = true;
209                }
210                t if t.starts_with("last") && t.ends_with("days") => {
211                    if let Ok(n) = t[4..t.len() - 4].parse::<i64>() {
212                        let ts = local_midnight_coredata(-n);
213                        pq.clauses.push("n.ZMODIFICATIONDATE >= ?".to_string());
214                        pq.params.push(ts.to_string());
215                    }
216                }
217                t if t.starts_with("created") && t.ends_with("days") => {
218                    if let Ok(n) = t[7..t.len() - 4].parse::<i64>() {
219                        let ts = local_midnight_coredata(-n);
220                        pq.clauses.push("n.ZCREATIONDATE >= ?".to_string());
221                        pq.params.push(ts.to_string());
222                    }
223                }
224                t if t.starts_with("date(") && t.ends_with(')') => {
225                    let date_str = &t[5..t.len() - 1];
226                    if let Some((start, end)) = parse_date_range_coredata(date_str) {
227                        pq.clauses.push(
228                            "(n.ZMODIFICATIONDATE >= ? AND n.ZMODIFICATIONDATE < ?)".to_string(),
229                        );
230                        pq.params.push(start.to_string());
231                        pq.params.push(end.to_string());
232                    }
233                }
234                t if t.starts_with("cdate(") && t.ends_with(')') => {
235                    let date_str = &t[6..t.len() - 1];
236                    if let Some((start, end)) = parse_date_range_coredata(date_str) {
237                        pq.clauses
238                            .push("(n.ZCREATIONDATE >= ? AND n.ZCREATIONDATE < ?)".to_string());
239                        pq.params.push(start.to_string());
240                        pq.params.push(end.to_string());
241                    }
242                }
243                // unsupported: silently skip
244                "ocr" | "wikilinks" | "backlinks" | "readonly" => {
245                    eprintln!("warning: @{token} is not supported, skipping");
246                }
247                _ => {
248                    eprintln!("warning: unknown token @{token}, skipping");
249                }
250            }
251        } else {
252            // bare term
253            let term = read_token(&mut chars);
254            if !term.is_empty() {
255                if title_only {
256                    pq.push_like("n.ZTITLE", &term);
257                    title_only = false;
258                } else {
259                    pq.push_text_or_title_like(&term);
260                }
261            }
262        }
263    }
264
265    pq
266}
267
268fn read_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
269    let mut s = String::new();
270    while let Some(&c) = chars.peek() {
271        if c.is_whitespace() {
272            break;
273        }
274        s.push(c);
275        chars.next();
276    }
277    s
278}
279
280/// CoreData timestamp for local midnight N days from today (negative = past).
281fn local_midnight_coredata(days_offset: i64) -> f64 {
282    let today = Local::now().date_naive();
283    let target = today + Duration::days(days_offset);
284    let midnight = Local
285        .from_local_datetime(&target.and_hms_opt(0, 0, 0).unwrap())
286        .single()
287        .map(|dt| dt.timestamp())
288        .unwrap_or_else(|| chrono::Utc::now().timestamp());
289    crate::db::unix_to_coredata(midnight)
290}
291
292/// Parse "YYYY-MM-DD" into a [start, end) CoreData range (one full day).
293fn parse_date_range_coredata(s: &str) -> Option<(f64, f64)> {
294    let date = NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()?;
295    let start_unix = Local
296        .from_local_datetime(&date.and_hms_opt(0, 0, 0)?)
297        .single()?
298        .timestamp();
299    let end_unix = start_unix + 86_400;
300    Some((
301        crate::db::unix_to_coredata(start_unix),
302        crate::db::unix_to_coredata(end_unix),
303    ))
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn parse_empty_query() {
312        let pq = parse_query("");
313        assert!(pq.clauses.is_empty());
314        assert!(pq.params.is_empty());
315    }
316
317    #[test]
318    fn parse_bare_term() {
319        let pq = parse_query("meeting");
320        assert_eq!(pq.clauses.len(), 1);
321        assert!(pq.clauses[0].contains("LIKE"));
322        assert_eq!(pq.params.len(), 2); // text + title
323    }
324
325    #[test]
326    fn parse_negation() {
327        let pq = parse_query("-draft");
328        assert_eq!(pq.clauses.len(), 1);
329        assert!(pq.clauses[0].contains("NOT LIKE"));
330    }
331
332    #[test]
333    fn parse_at_todo() {
334        let pq = parse_query("@todo");
335        assert_eq!(pq.clauses.len(), 1);
336        assert_eq!(pq.clauses[0], "n.ZTODOINCOMPLETED > 0");
337        assert!(pq.params.is_empty());
338    }
339
340    #[test]
341    fn parse_tag() {
342        let pq = parse_query("#work");
343        assert!(pq.joins.len() == 1);
344        assert!(pq.joins[0].contains("ZSFNOTETAG"));
345        assert_eq!(pq.params[0], "work");
346    }
347
348    #[test]
349    fn parse_combined() {
350        let pq = parse_query("meeting #work @today");
351        assert_eq!(pq.joins.len(), 1);
352        assert_eq!(pq.clauses.len(), 2); // text/title LIKE + ZMODIFICATIONDATE >=
353    }
354
355    #[test]
356    fn escape_like_special_chars() {
357        assert_eq!(escape_like("50%"), "50\\%");
358        assert_eq!(escape_like("a_b"), "a\\_b");
359    }
360}