working prototype
This commit is contained in:
179
src/parser.rs
Normal file
179
src/parser.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::{
|
||||
Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday,
|
||||
};
|
||||
use regex::Regex;
|
||||
|
||||
/// Extracts the first recognizable date/time phrase and returns local RFC 3339.
|
||||
/// Deliberately favors useful, predictable planner phrases over pretending to be
|
||||
/// a complete natural-language parser.
|
||||
pub fn extract_when(text: &str) -> Option<String> {
|
||||
extract_when_from(text, Local::now().naive_local())
|
||||
}
|
||||
|
||||
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
||||
let lower = text.to_lowercase();
|
||||
let mut date = None;
|
||||
|
||||
let iso = Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").unwrap();
|
||||
if let Some(c) = iso.captures(&lower) {
|
||||
date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?);
|
||||
}
|
||||
|
||||
if date.is_none() {
|
||||
let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap();
|
||||
if let Some(c) = in_days.captures(&lower) {
|
||||
date = Some(now.date() + Duration::days(c[1].parse().ok()?));
|
||||
} else if lower.contains("day after tomorrow") {
|
||||
date = Some(now.date() + Duration::days(2));
|
||||
} else if lower.contains("tomorrow") {
|
||||
date = Some(now.date() + Duration::days(1));
|
||||
} else if lower.contains("today") || lower.contains("tonight") {
|
||||
date = Some(now.date());
|
||||
}
|
||||
}
|
||||
|
||||
if date.is_none() {
|
||||
let weekdays = [
|
||||
("monday", Weekday::Mon),
|
||||
("tuesday", Weekday::Tue),
|
||||
("wednesday", Weekday::Wed),
|
||||
("thursday", Weekday::Thu),
|
||||
("friday", Weekday::Fri),
|
||||
("saturday", Weekday::Sat),
|
||||
("sunday", Weekday::Sun),
|
||||
];
|
||||
for (word, weekday) in weekdays {
|
||||
if lower.contains(word) {
|
||||
let mut delta = (weekday.num_days_from_monday() as i64
|
||||
- now.weekday().num_days_from_monday() as i64
|
||||
+ 7)
|
||||
% 7;
|
||||
// Treat both "Friday" and "next Friday" as the next occurrence.
|
||||
// This mirrors planner expectations and avoids surprising dates
|
||||
// almost two weeks away when the weekday is still ahead.
|
||||
if delta == 0 {
|
||||
delta += 7;
|
||||
}
|
||||
date = Some(now.date() + Duration::days(delta));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let date = date?;
|
||||
let time_re = Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").unwrap();
|
||||
let time24_re = Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").unwrap();
|
||||
let time = if let Some(c) = time_re.captures(&lower) {
|
||||
let mut hour: u32 = c[1].parse().ok()?;
|
||||
let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?;
|
||||
if hour == 12 {
|
||||
hour = 0;
|
||||
}
|
||||
if &c[3] == "pm" {
|
||||
hour += 12;
|
||||
}
|
||||
NaiveTime::from_hms_opt(hour, minute, 0)?
|
||||
} else if let Some(c) = time24_re.captures(&lower) {
|
||||
NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)?
|
||||
} else {
|
||||
NaiveTime::from_hms_opt(9, 0, 0)?
|
||||
};
|
||||
|
||||
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
|
||||
Some(local.to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn pretty_when(value: Option<&str>) -> String {
|
||||
let Some(value) = value else {
|
||||
return String::new();
|
||||
};
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|_| value.to_owned())
|
||||
}
|
||||
|
||||
pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> {
|
||||
let current = chrono::DateTime::parse_from_rfc3339(current)?.with_timezone(&Local);
|
||||
let rule = recurrence.trim().to_lowercase();
|
||||
let next = match rule.as_str() {
|
||||
"daily" => current + Duration::days(1),
|
||||
"weekdays" => {
|
||||
let mut next = current + Duration::days(1);
|
||||
while matches!(next.weekday(), Weekday::Sat | Weekday::Sun) {
|
||||
next += Duration::days(1)
|
||||
}
|
||||
next
|
||||
}
|
||||
"weekly" => current + Duration::weeks(1),
|
||||
"monthly" => current
|
||||
.checked_add_months(Months::new(1))
|
||||
.ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?,
|
||||
"yearly" | "annually" => current
|
||||
.checked_add_months(Months::new(12))
|
||||
.ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?,
|
||||
_ => {
|
||||
let re = Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap();
|
||||
let Some(c) = re.captures(&rule) else {
|
||||
bail!(
|
||||
"recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'"
|
||||
)
|
||||
};
|
||||
let n: u32 = c[1].parse()?;
|
||||
match &c[2] {
|
||||
"day" | "days" => current + Duration::days(n as i64),
|
||||
"week" | "weeks" => current + Duration::weeks(n as i64),
|
||||
"month" | "months" => current
|
||||
.checked_add_months(Months::new(n))
|
||||
.ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?,
|
||||
_ => current
|
||||
.checked_add_months(Months::new(n.saturating_mul(12)))
|
||||
.ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?,
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(next.to_rfc3339())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base() -> NaiveDateTime {
|
||||
NaiveDate::from_ymd_opt(2026, 8, 16)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_relative_dates_and_time() {
|
||||
let got = extract_when_from("Call Ada tomorrow at 3:30pm", base()).unwrap();
|
||||
assert!(got.contains("2026-08-17T15:30:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_next_weekday() {
|
||||
let got = extract_when_from("Review next Friday", base()).unwrap();
|
||||
assert!(got.contains("2026-08-21T09:00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_undated_text() {
|
||||
assert_eq!(extract_when_from("An evergreen idea", base()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advances_recurring_dates() {
|
||||
assert!(
|
||||
next_occurrence("2026-08-14T09:00:00+00:00", "weekdays")
|
||||
.unwrap()
|
||||
.contains("2026-08-17T09:00:00")
|
||||
);
|
||||
assert!(
|
||||
next_occurrence("2026-08-16T09:00:00+00:00", "every 2 weeks")
|
||||
.unwrap()
|
||||
.contains("2026-08-30T09:00:00")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user