use crate::types::{ClassificationResult, NoteType, RecurrenceRule}; use crate::util::local_naive_to_utc; use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday}; use regex::Regex; use std::sync::OnceLock; struct Patterns { at_time: Regex, in_duration: Regex, in_duration_word: Regex, tomorrow: Regex, next_weekday: Regex, tonight: Regex, every_weekdays: Regex, every_weekday: Regex, every_week: Regex, every_day: Regex, morning_evening: Regex, } static PATTERNS: OnceLock = OnceLock::new(); fn patterns() -> &'static Patterns { PATTERNS.get_or_init(|| Patterns { at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(), in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(), // Word-form durations: "in an hour", "in a couple of hours", "in half an hour" in_duration_word: Regex::new( r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)" ).unwrap(), tomorrow: Regex::new(r"(?i)\btomorrow(?:\s+morning|\s+evening|\s+afternoon)?").unwrap(), next_weekday: Regex::new(r"(?i)\bnext\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)").unwrap(), // "tonight" or "this evening" — maps to a fixed 21:00 anchor tonight: Regex::new(r"(?i)\b(?:tonight|this\s+evening)\b").unwrap(), // "every weekday [at H:MM|morning|afternoon|evening]" → Mon–Fri RRULE every_weekdays: Regex::new( r"(?i)\bevery\s+weekday(?:\s+(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?|(morning|afternoon|evening)))?" ).unwrap(), every_weekday: Regex::new(r"(?i)\bevery\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(), // \bweek\b prevents "weekday" from being matched here every_week: Regex::new(r"(?i)\bevery\s+week\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(), every_day: Regex::new(r"(?i)\bevery\s+day(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(), // Strips stray time-of-day words after a time has been extracted. // Handles compound forms ("this morning") before bare words to avoid partial matches. morning_evening: Regex::new( r"(?i)\b(?:this\s+(?:morning|evening|afternoon|night)|tonight|morning|evening|afternoon|night)\b" ).unwrap(), }) } fn parse_time_of_day(hour: &str, min: Option<&str>, ampm: Option<&str>) -> NaiveTime { let mut h: u32 = hour.parse().unwrap_or(9); let m: u32 = min.and_then(|s| s.parse().ok()).unwrap_or(0); if let Some(ap) = ampm { if ap.eq_ignore_ascii_case("pm") && h < 12 { h += 12; } else if ap.eq_ignore_ascii_case("am") && h == 12 { h = 0; } } NaiveTime::from_hms_opt(h, m, 0).unwrap_or(NaiveTime::from_hms_opt(9, 0, 0).unwrap()) } fn weekday_from_str(s: &str) -> Weekday { match s.to_lowercase().as_str() { "monday" => Weekday::Mon, "tuesday" => Weekday::Tue, "wednesday" => Weekday::Wed, "thursday" => Weekday::Thu, "friday" => Weekday::Fri, "saturday" => Weekday::Sat, _ => Weekday::Sun, } } fn rrule_weekday(wd: Weekday) -> &'static str { match wd { Weekday::Mon => "MO", Weekday::Tue => "TU", Weekday::Wed => "WE", Weekday::Thu => "TH", Weekday::Fri => "FR", Weekday::Sat => "SA", Weekday::Sun => "SU", } } fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime { let local = Local::now(); let days_ahead = (wd.num_days_from_monday() as i64 - local.weekday().num_days_from_monday() as i64) .rem_euclid(7); let days_ahead = if days_ahead == 0 { if local.time() < time { 0 } else { 7 } } else { days_ahead }; let target_date = local.date_naive() + Duration::days(days_ahead); let naive = target_date.and_time(time); local_naive_to_utc(naive) } pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult { let p = patterns(); let morning_time: NaiveTime = default_morning .split(':') .collect::>() .as_slice() .get(..2) .and_then(|parts| { let h: u32 = parts[0].parse().ok()?; let m: u32 = parts[1].parse().ok()?; NaiveTime::from_hms_opt(h, m, 0) }) .unwrap_or(NaiveTime::from_hms_opt(8, 0, 0).unwrap()); let evening_time = NaiveTime::from_hms_opt(18, 0, 0).unwrap(); let mut extracted_time: Option> = None; let mut rrule: Option = None; let mut cleaned = text.to_string(); // Recurrence: every day if let Some(m) = p.every_day.find(text) { let caps = p.every_day.captures(text).unwrap(); let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) { parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str())) } else { morning_time }; rrule = Some(RecurrenceRule::new(format!( "RRULE:FREQ=DAILY;BYHOUR={};BYMINUTE={};BYSECOND=0", t.hour(), t.minute() ))); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); } // Recurrence: every week else if let Some(m) = p.every_week.find(text) { let caps = p.every_week.captures(text).unwrap(); let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) { parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str())) } else { morning_time }; let now = Local::now(); let wd = rrule_weekday(now.weekday()); rrule = Some(RecurrenceRule::new(format!( "RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0", wd, t.hour(), t.minute() ))); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); } // Recurrence: every weekday (Mon–Fri) else if let Some(caps) = p.every_weekdays.captures(text) { let t = if let Some(h) = caps.get(1) { parse_time_of_day(h.as_str(), caps.get(2).map(|x| x.as_str()), caps.get(3).map(|x| x.as_str())) } else { match caps.get(4).map(|x| x.as_str().to_lowercase()).as_deref() { Some("afternoon") => NaiveTime::from_hms_opt(14, 0, 0).unwrap(), Some("evening") => evening_time, _ => morning_time, } }; rrule = Some(RecurrenceRule::new(format!( "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR={};BYMINUTE={};BYSECOND=0", t.hour(), t.minute() ))); let full_match = caps.get(0).unwrap().as_str(); cleaned = cleaned.replacen(full_match, "", 1).trim().to_string(); } // Recurrence: every else if let Some(caps) = p.every_weekday.captures(text) { let wd_str = caps.get(1).unwrap().as_str(); let wd = weekday_from_str(wd_str); let t = if let (Some(h), mp, ap) = (caps.get(2), caps.get(3), caps.get(4)) { parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str())) } else { morning_time }; rrule = Some(RecurrenceRule::new(format!( "RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0", rrule_weekday(wd), t.hour(), t.minute() ))); extracted_time = Some(next_occurrence_of_weekday(wd, t)); let full_match = caps.get(0).unwrap().as_str(); cleaned = cleaned.replacen(full_match, "", 1).trim().to_string(); } // One-off: at