1use crate::tasks::TaskEdit;
27use crate::words::{CaptureWords, TaskKinds, folded};
28use jiff::Span;
29use jiff::civil::{Date, Weekday};
30use serde::Serialize;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct Chip {
35 pub kind: String,
37 pub text: String,
39 pub value: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct Captured {
46 pub edit: TaskEdit,
47 pub chips: Vec<Chip>,
48}
49
50fn 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
74fn 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
80fn add_days(date: Date, days: i64) -> Option<Date> {
82 date.checked_add(Span::new().try_days(days).ok()?).ok()
83}
84
85fn 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
92pub fn parse_day(text: &str, today: Date) -> Option<Date> {
94 parse_day_with(&crate::words::current().capture, text, today)
95}
96
97pub 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 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 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
168pub 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
180pub 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
187pub 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 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 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 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 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
277fn 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
288fn 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
305fn 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 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 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 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 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 #[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 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}