1use chrono::{Duration, Local, NaiveDate, TimeZone};
36
37pub struct ParsedQuery {
39 pub clauses: Vec<String>,
42 pub params: Vec<String>,
44 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
96pub 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 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 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 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 if ch == '!' {
136 chars.next(); }
138 chars.next(); 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(); 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 "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 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
280fn 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
292fn 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); }
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); }
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}