Skip to main content

sioul_core/
capture.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Quick capture: one line typed, a task made. What the line says besides
5//! the title is shown as chips before the task is made, so nothing is misread
6//! silently (docs/tasks.md).
7//!
8//! At the end of the line, in the languages in use (`words::CaptureWords`;
9//! English and French built in), in any order:
10//! - **the day to start**: "today", "tomorrow", "friday", "next week",
11//!   "in 3 days", "30/10", "30 oct", "2026-10-30" ("aujourd'hui", "demain",
12//!   "vendredi", "semaine prochaine", "dans 3 jours", "30 octobre");
13//! - **how long**: "~15m", "~1h", "~1h30";
14//! - **the date asked**, in braces: "{30/10}", "{vendredi}";
15//! - **a project or a tag**: "#housing".
16//!
17//! Anywhere in the line, "@tomorrow" or "@30/10" gives the day to start, and
18//! "@call", "@write", "@online", "@out", "@read", "@think", "@make" (or in
19//! French: "@appel", "@écrire", "@enligne", "@dehors", "@lire",
20//! "@réfléchir", "@faire") the task's kind, as does "@" and the name of a
21//! kind you added ("@errand"). Without one, a first word that says it
22//! plainly gives it: "Call the bank" is a call, "Remplir le formulaire" a
23//! form online. A kind you took away is never given. Words are compared
24//! folded: capitals and accents aside ("fevrier", "Écrire").
25
26use crate::tasks::TaskEdit;
27use crate::words::{CaptureWords, TaskKinds, folded};
28use jiff::Span;
29use jiff::civil::{Date, Weekday};
30use serde::Serialize;
31
32/// Something the line said besides the title.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Chip {
35    /// "start", "due", "estimate", "project", "tag", "kind".
36    pub kind: String,
37    /// As typed.
38    pub text: String,
39    /// As understood: "2026-10-30", "15", "housing".
40    pub value: String,
41}
42
43/// A captured line: the task it makes, and the chips to show.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct Captured {
46    pub edit: TaskEdit,
47    pub chips: Vec<Chip>,
48}
49
50/// Whether a folded word or phrase is one of a list's, folded, its spaces made one.
51fn said(list: &[String], phrase: &str) -> bool {
52    list.iter().any(|p| {
53        let p = folded(p);
54        !p.is_empty() && p.split_whitespace().collect::<Vec<_>>().join(" ") == phrase
55    })
56}
57
58fn weekday(w: &CaptureWords, word: &str) -> Option<Weekday> {
59    let d = &w.weekdays;
60    [
61        (&d.monday, Weekday::Monday),
62        (&d.tuesday, Weekday::Tuesday),
63        (&d.wednesday, Weekday::Wednesday),
64        (&d.thursday, Weekday::Thursday),
65        (&d.friday, Weekday::Friday),
66        (&d.saturday, Weekday::Saturday),
67        (&d.sunday, Weekday::Sunday),
68    ]
69    .into_iter()
70    .find(|(list, _)| said(list, word))
71    .map(|(_, day)| day)
72}
73
74/// A month by one of its names (`CaptureWords::months`, by number), a dot after it or not.
75fn month(w: &CaptureWords, word: &str) -> Option<i8> {
76    let word = word.trim_end_matches('.');
77    w.months.iter().find(|(_, names)| said(names, word)).and_then(|(number, _)| number.trim().parse().ok()).filter(|m| (1..=12).contains(m))
78}
79
80/// None past what a calendar holds ("in 99999999 days"), where `Span::days` would panic.
81fn add_days(date: Date, days: i64) -> Option<Date> {
82    date.checked_add(Span::new().try_days(days).ok()?).ok()
83}
84
85/// A day and month without a year: the next one from today.
86fn next_date(today: Date, month: i8, day: i8, year: Option<i16>) -> Option<Date> {
87    let year = year.map(|y| if y < 100 { 2000 + y } else { y });
88    let date = Date::new(year.unwrap_or(today.year()), month, day).ok()?;
89    if year.is_none() && date < today { Date::new(today.year() + 1, month, day).ok() } else { Some(date) }
90}
91
92/// A day, as typed: one to three words, in the languages in use (`words::current`).
93pub fn parse_day(text: &str, today: Date) -> Option<Date> {
94    parse_day_with(&crate::words::current().capture, text, today)
95}
96
97/// A day, as typed, read with these words.
98pub fn parse_day_with(w: &CaptureWords, text: &str, today: Date) -> Option<Date> {
99    let lower = folded(text);
100    let words: Vec<&str> = lower.split_whitespace().collect();
101    let phrase = words.join(" ");
102    if said(&w.today, &phrase) {
103        return Some(today);
104    }
105    if said(&w.tomorrow, &phrase) {
106        return add_days(today, 1);
107    }
108    if said(&w.next_week, &phrase) {
109        let to_monday = 7 - i64::from(today.weekday().to_monday_zero_offset());
110        return add_days(today, to_monday);
111    }
112    if let [first, n, unit] = words.as_slice()
113        && said(&w.in_, first)
114    {
115        let n: i64 = n.parse().ok()?;
116        let days = if said(&w.unit_days, unit) {
117            n
118        } else if said(&w.unit_weeks, unit) {
119            n.checked_mul(7)?
120        } else {
121            return None;
122        };
123        return add_days(today, days);
124    }
125    match words.as_slice() {
126        [word] => {
127            if let Some(day) = weekday(w, word) {
128                // The next one: a Friday said on a Friday is a week away.
129                let ahead = (i64::from(day.to_monday_zero_offset()) - i64::from(today.weekday().to_monday_zero_offset()) + 7) % 7;
130                return add_days(today, if ahead == 0 { 7 } else { ahead });
131            }
132            if let Ok(date) = word.parse::<Date>() {
133                return Some(date);
134            }
135            let parts: Vec<&str> = word.split(['/', '.']).collect();
136            if let [d, m, rest @ ..] = parts.as_slice()
137                && rest.len() <= 1
138            {
139                let year = rest.first().and_then(|y| y.parse::<i16>().ok());
140                return next_date(today, m.parse().ok()?, d.parse().ok()?, year);
141            }
142        }
143        [a, b] | [a, b, _] => {
144            let year = words.get(2).and_then(|y| y.parse::<i16>().ok());
145            if words.len() == 3 && year.is_none() {
146                return None;
147            }
148            // "1er", "2nd": the day's ordinal suffixes off (`CaptureWords::ordinals`).
149            let number = |word: &str| {
150                let mut word = word;
151                for suffix in w.ordinals.iter().map(|o| folded(o)).filter(|o| !o.is_empty()) {
152                    word = word.trim_end_matches(suffix.as_str());
153                }
154                word.parse::<i8>().ok()
155            };
156            if let (Some(day), Some(m)) = (number(a), month(w, b)) {
157                return next_date(today, m, day, year);
158            }
159            if let (Some(m), Some(day)) = (month(w, a), number(b)) {
160                return next_date(today, m, day, year);
161            }
162        }
163        _ => {}
164    }
165    None
166}
167
168/// "~15m", "~1h", "~1h30", "~90" → minutes.
169pub fn parse_minutes(text: &str) -> Option<u32> {
170    let text = text.trim().trim_start_matches('~').to_lowercase();
171    let text = text.trim_end_matches("min").trim_end_matches('m');
172    if let Some((hours, minutes)) = text.split_once('h') {
173        let hours: u32 = hours.parse().ok()?;
174        let minutes: u32 = if minutes.is_empty() { 0 } else { minutes.parse().ok()? };
175        return hours.checked_mul(60)?.checked_add(minutes).filter(|&m| m > 0);
176    }
177    text.parse().ok().filter(|&m: &u32| m > 0)
178}
179
180/// Reads one typed line. `projects` are the project ids a "#word" may name; any
181/// other "#word" is a tag (CATEGORIES). `kinds` are your kinds, id and name;
182/// empty, Sioul's. The words are those in use (`words::current`).
183pub fn capture(line: &str, today: Date, projects: &[String], kinds: &[(String, String)]) -> Captured {
184    capture_with(&crate::words::current().capture, line, today, projects, kinds)
185}
186
187/// `capture`, read with these words.
188pub fn capture_with(w: &CaptureWords, line: &str, today: Date, projects: &[String], kinds: &[(String, String)]) -> Captured {
189    let mut edit = TaskEdit::default();
190    let mut chips: Vec<Chip> = Vec::new();
191    let mut words: Vec<String> = line.split_whitespace().map(str::to_string).collect();
192    let chip = |kind: &str, text: &str, value: String| Chip { kind: kind.into(), text: text.into(), value };
193
194    // "@day" anywhere.
195    words.retain(|word| {
196        let Some(day) = word.strip_prefix('@').and_then(|d| parse_day_with(w, d, today)) else { return true };
197        if edit.start.is_empty() {
198            edit.start = day.to_string();
199            chips.push(chip("start", word, day.to_string()));
200            return false;
201        }
202        true
203    });
204
205    // "@kind" anywhere.
206    words.retain(|word| {
207        let Some(kind) = word.strip_prefix('@').and_then(|k| named_kind(&w.kind_words, k, kinds)) else { return true };
208        if edit.kind.is_empty() {
209            edit.kind = kind.clone();
210            chips.push(chip("kind", word, kind));
211            return false;
212        }
213        true
214    });
215
216    // Then from the end, as long as words say something.
217    loop {
218        let Some(last) = words.last().cloned() else { break };
219        if last.starts_with('~')
220            && let Some(minutes) = parse_minutes(&last)
221            && edit.estimate == 0
222        {
223            edit.estimate = minutes;
224            chips.push(chip("estimate", &last, minutes.to_string()));
225            words.pop();
226            continue;
227        }
228        if let Some(tag) = last.strip_prefix('#').filter(|t| !t.is_empty()) {
229            let is_project = projects.iter().any(|c| c.eq_ignore_ascii_case(tag));
230            if is_project {
231                edit.projects.push(tag.to_string());
232            } else {
233                edit.categories.push(tag.to_string());
234            }
235            chips.push(chip(if is_project { "project" } else { "tag" }, &last, tag.to_string()));
236            words.pop();
237            continue;
238        }
239        if last.ends_with('}') && edit.due.is_empty() {
240            let open = words.iter().rposition(|w| w.starts_with('{'));
241            if let Some(open) = open {
242                let typed = words[open..].join(" ");
243                if let Some(day) = parse_day_with(w, typed.trim_start_matches('{').trim_end_matches('}'), today) {
244                    edit.due = day.to_string();
245                    chips.push(chip("due", &typed, day.to_string()));
246                    words.truncate(open);
247                    continue;
248                }
249            }
250        }
251        if edit.start.is_empty() {
252            let found = (1..=3usize).rev().filter(|&n| n <= words.len()).find_map(|n| {
253                let typed = words[words.len() - n..].join(" ");
254                parse_day_with(w, &typed, today).map(|day| (n, typed, day))
255            });
256            // A lone number is part of the title ("Room 12"), not a day.
257            if let Some((n, typed, day)) = found.filter(|(n, typed, _)| *n > 1 || typed.contains(['/', '-', '.']) || !typed.chars().all(|c| c.is_ascii_digit())) {
258                edit.start = day.to_string();
259                chips.push(chip("start", &typed, day.to_string()));
260                words.truncate(words.len() - n);
261                continue;
262            }
263        }
264        break;
265    }
266    chips.reverse();
267    edit.title = words.join(" ");
268    if edit.kind.is_empty()
269        && let Some(kind) = words.first().and_then(|first| guess_kind(&w.kind_verbs, first)).filter(|k| kinds.is_empty() || kinds.iter().any(|(id, _)| id == k))
270    {
271        edit.kind = kind.to_string();
272        chips.push(chip("kind", "", kind.to_string()));
273    }
274    Captured { edit, chips }
275}
276
277/// The kind a word after "@" names: one of Sioul's (`kind_words`, in the
278/// languages in use) while you keep it; else one of yours, by its id or its name.
279fn named_kind(kind_words: &TaskKinds, word: &str, kinds: &[(String, String)]) -> Option<String> {
280    if let Some(kind) = kind_in(kind_words, word).filter(|k| kinds.is_empty() || kinds.iter().any(|(id, _)| id == k)) {
281        return Some(kind.to_string());
282    }
283    let fold = |s: &str| crate::text::fold(s).into_iter().filter(|c| !c.is_whitespace()).collect::<String>();
284    let wanted = fold(word);
285    kinds.iter().find(|(id, label)| *id == wanted || fold(label) == wanted).map(|(id, _)| id.clone())
286}
287
288/// The kind of Sioul's (`tasks::KINDS`) whose words hold `word`, folded.
289fn kind_in(kinds: &TaskKinds, word: &str) -> Option<&'static str> {
290    let word = folded(word);
291    [
292        ("call", &kinds.call),
293        ("write", &kinds.write),
294        ("online", &kinds.online),
295        ("out", &kinds.out),
296        ("read", &kinds.read),
297        ("think", &kinds.think),
298        ("make", &kinds.make),
299    ]
300    .into_iter()
301    .find(|(_, list)| said(list, &word))
302    .map(|(kind, _)| kind)
303}
304
305/// The kind a first word says plainly (`kind_verbs`); none for a word that
306/// could be several ("ask", "demander": a call or a message?).
307fn guess_kind(kind_verbs: &TaskKinds, word: &str) -> Option<&'static str> {
308    kind_in(kind_verbs, word.trim_end_matches([',', '.', ':', ';']))
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    /// Saturday 3 October 2026.
316    fn today() -> Date {
317        "2026-10-03".parse().unwrap()
318    }
319
320    fn day(text: &str) -> Option<String> {
321        parse_day(text, today()).map(|d| d.to_string())
322    }
323
324    #[test]
325    fn kinds_typed_or_plain() {
326        let projects: Vec<String> = Vec::new();
327        let typed = capture("Banque @appel demain ~15m", today(), &projects, &[]);
328        assert_eq!((typed.edit.title.as_str(), typed.edit.kind.as_str(), typed.edit.start.as_str()), ("Banque", "call", "2026-10-04"));
329        assert_eq!(capture("Remplir le formulaire de la CAF", today(), &projects, &[]).edit.kind, "online");
330        assert_eq!(capture("Call the bank", today(), &projects, &[]).edit.kind, "call");
331        assert_eq!(capture("Ask the lawyer", today(), &projects, &[]).edit.kind, "", "a word that could be several says nothing");
332        assert_eq!(capture("Écrire au propriétaire", today(), &projects, &[]).edit.kind, "write");
333        let both = capture("Call the bank @write", today(), &projects, &[]);
334        assert_eq!(both.edit.kind, "write", "what is typed wins over the first word");
335        assert_eq!(both.chips.iter().filter(|c| c.kind == "kind").count(), 1);
336        // Your kinds: one added, "think" taken away.
337        let yours = vec![("call".to_string(), "Call".to_string()), ("rendez-vous".to_string(), "Rendez-vous".to_string())];
338        assert_eq!(capture("Dentist @rendez-vous", today(), &projects, &yours).edit.kind, "rendez-vous");
339        assert_eq!(capture("Call the bank", today(), &projects, &yours).edit.kind, "call");
340        assert_eq!(capture("Decide @think", today(), &projects, &yours).edit.kind, "", "a kind taken away is never given");
341        assert_eq!(capture("Decide on the flat", today(), &projects, &yours).edit.kind, "");
342    }
343
344    #[test]
345    fn days_in_both_languages() {
346        assert_eq!(day("tomorrow").as_deref(), Some("2026-10-04"));
347        assert_eq!(day("demain").as_deref(), Some("2026-10-04"));
348        assert_eq!(day("vendredi").as_deref(), Some("2026-10-09"));
349        assert_eq!(day("saturday").as_deref(), Some("2026-10-10"), "a Saturday said on a Saturday is a week away");
350        assert_eq!(day("next week").as_deref(), Some("2026-10-05"));
351        assert_eq!(day("semaine prochaine").as_deref(), Some("2026-10-05"));
352        assert_eq!(day("dans 3 jours").as_deref(), Some("2026-10-06"));
353        assert_eq!(day("30/10").as_deref(), Some("2026-10-30"));
354        assert_eq!(day("2/1").as_deref(), Some("2027-01-02"), "past this year: next year");
355        assert_eq!(day("30 octobre").as_deref(), Some("2026-10-30"));
356        assert_eq!(day("1er novembre").as_deref(), Some("2026-11-01"));
357        assert_eq!(day("Oct 30").as_deref(), Some("2026-10-30"));
358        assert_eq!(day("2026-12-24").as_deref(), Some("2026-12-24"));
359        assert_eq!(day("maybe"), None);
360        assert_eq!((parse_minutes("~15m"), parse_minutes("~1h30"), parse_minutes("~2h"), parse_minutes("~x")), (Some(15), Some(90), Some(120), None));
361        // Typed by mistake: nothing, and no crash.
362        assert_eq!((day("in 99999999 days"), day("dans 9999999999999999999 semaines"), day("in 2000000000000000000 weeks")), (None, None, None));
363        assert_eq!(parse_minutes("~99999999h"), None);
364    }
365
366    #[test]
367    fn one_line_one_task() {
368        let projects = vec!["housing".to_string()];
369        let got = capture("Call the CAF tomorrow ~15m #housing {30 oct}", today(), &projects, &[]);
370        assert_eq!(got.edit.title, "Call the CAF");
371        assert_eq!((got.edit.start.as_str(), got.edit.due.as_str(), got.edit.estimate), ("2026-10-04", "2026-10-30", 15));
372        assert_eq!((got.edit.projects.clone(), got.edit.categories.clone()), (vec!["housing".to_string()], vec![]));
373        // "Call" says the kind plainly: its chip comes last, as nothing typed it.
374        assert_eq!(got.chips.iter().map(|c| c.kind.as_str()).collect::<Vec<_>>(), vec!["start", "estimate", "project", "due", "kind"]);
375        assert_eq!(got.edit.kind, "call");
376        let tagged = capture("Prepare @lundi the tomorrow meeting notes #health", today(), &projects, &[]);
377        assert_eq!((tagged.edit.title.as_str(), tagged.edit.start.as_str()), ("Prepare the tomorrow meeting notes", "2026-10-05"));
378        assert_eq!(tagged.edit.categories, vec!["health"]);
379        assert_eq!(capture("Book room 12", today(), &projects, &[]).edit.title, "Book room 12");
380    }
381
382    /// Words added in your configuration: German days, a German "in", a German verb.
383    #[test]
384    fn your_words_are_read() {
385        let config: crate::config::Config = toml::from_str(
386            "[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.capture.tomorrow]\nadd = [\"morgen\"]\n[words.capture.weekdays.friday]\nadd = [\"Freitag\"]\n[words.capture.in]\nadd = [\"in\"]\n[words.capture.unit_days]\nadd = [\"Tagen\"]\n[words.capture.months]\nadd = { 3 = [\"März\"] }\n[words.capture.kind_verbs.call]\nadd = [\"anrufen\"]\n",
387        )
388        .unwrap();
389        let yours = crate::words::Words::of(&config);
390        let day = |text: &str| parse_day_with(&yours.capture, text, today()).map(|d| d.to_string());
391        assert_eq!(day("morgen").as_deref(), Some("2026-10-04"));
392        assert_eq!(day("FREITAG").as_deref(), Some("2026-10-09"));
393        assert_eq!(day("in 3 Tagen").as_deref(), Some("2026-10-06"));
394        assert_eq!(day("3 märz").as_deref(), Some("2027-03-03"));
395        assert_eq!(day("3 marz").as_deref(), Some("2027-03-03"), "accents aside");
396        assert_eq!(capture_with(&yours.capture, "Anrufen Bank morgen", today(), &[], &[]).edit.kind, "call");
397        assert_eq!(parse_day("morgen", today()), None, "not in the packs");
398        assert_eq!(capture("Anrufen Bank", today(), &[], &[]).edit.kind, "");
399        // Accents typed or not, as before.
400        assert_eq!((day("fevrier 2").as_deref(), day("2 févr.").as_deref(), day("Aout 15").as_deref()), (Some("2027-02-02"), Some("2027-02-02"), Some("2027-08-15")));
401    }
402}