299 lines
9.9 KiB
Rust
299 lines
9.9 KiB
Rust
use anyhow::{Result, bail};
|
|
use chrono::{
|
|
Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday,
|
|
};
|
|
use regex::Regex;
|
|
|
|
#[cfg(test)]
|
|
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
|
extract_when_from_configured(
|
|
text, now, "ymd", "monday", "09:00", "09:00", "13:00", "18:00",
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn extract_when_configured(
|
|
text: &str,
|
|
date_order: &str,
|
|
week_start: &str,
|
|
default_time: &str,
|
|
morning_time: &str,
|
|
afternoon_time: &str,
|
|
evening_time: &str,
|
|
) -> Option<String> {
|
|
extract_when_from_configured(
|
|
text,
|
|
Local::now().naive_local(),
|
|
date_order,
|
|
week_start,
|
|
default_time,
|
|
morning_time,
|
|
afternoon_time,
|
|
evening_time,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn extract_when_from_configured(
|
|
text: &str,
|
|
now: NaiveDateTime,
|
|
date_order: &str,
|
|
week_start: &str,
|
|
default_time: &str,
|
|
morning_time: &str,
|
|
afternoon_time: &str,
|
|
evening_time: &str,
|
|
) -> 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 numeric = Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").unwrap();
|
|
if let Some(c) = numeric.captures(&lower) {
|
|
let values = [
|
|
c[1].parse::<i32>().ok()?,
|
|
c[2].parse::<i32>().ok()?,
|
|
c[3].parse::<i32>().ok()?,
|
|
];
|
|
let (year, month, day) = match date_order {
|
|
"mdy" => (values[2], values[0], values[1]),
|
|
"dmy" => (values[2], values[1], values[0]),
|
|
_ => (values[0], values[1], values[2]),
|
|
};
|
|
let year = if year < 100 { year + 2000 } else { year };
|
|
date = NaiveDate::from_ymd_opt(year, month as u32, day as u32);
|
|
}
|
|
}
|
|
|
|
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() && (lower.contains("this week") || lower.contains("next week")) {
|
|
let day_from_start = if week_start == "sunday" {
|
|
now.weekday().num_days_from_sunday() as i64
|
|
} else {
|
|
now.weekday().num_days_from_monday() as i64
|
|
};
|
|
let current_start = now.date() - Duration::days(day_from_start);
|
|
date = Some(if lower.contains("next week") {
|
|
current_start + Duration::weeks(1)
|
|
} else {
|
|
current_start
|
|
});
|
|
}
|
|
|
|
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 if lower.contains("morning") {
|
|
parse_clock(morning_time)?
|
|
} else if lower.contains("afternoon") {
|
|
parse_clock(afternoon_time)?
|
|
} else if lower.contains("evening") || lower.contains("tonight") {
|
|
parse_clock(evening_time)?
|
|
} else {
|
|
parse_clock(default_time)?
|
|
};
|
|
|
|
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
|
|
Some(local.to_rfc3339())
|
|
}
|
|
|
|
fn parse_clock(value: &str) -> Option<NaiveTime> {
|
|
NaiveTime::parse_from_str(value, "%H:%M").ok()
|
|
}
|
|
|
|
pub fn format_when(value: Option<&str>, date_format: &str, clock_24h: bool) -> String {
|
|
let Some(value) = value else {
|
|
return String::new();
|
|
};
|
|
chrono::DateTime::parse_from_rfc3339(value)
|
|
.map(|d| {
|
|
let date = match date_format {
|
|
"us" => d.format("%m/%d/%Y").to_string(),
|
|
"european" => d.format("%d/%m/%Y").to_string(),
|
|
"long" => d.format("%b %-d, %Y").to_string(),
|
|
_ => d.format("%Y-%m-%d").to_string(),
|
|
};
|
|
let time = if clock_24h {
|
|
d.format("%H:%M").to_string()
|
|
} else {
|
|
d.format("%-I:%M%P").to_string()
|
|
};
|
|
format!("{date} {time}")
|
|
})
|
|
.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")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn honors_document_date_and_named_time_settings() {
|
|
let got = extract_when_from_configured(
|
|
"Planning on 18/08/2026 in the evening",
|
|
base(),
|
|
"dmy",
|
|
"monday",
|
|
"08:30",
|
|
"08:00",
|
|
"14:00",
|
|
"19:15",
|
|
)
|
|
.unwrap();
|
|
assert!(got.contains("2026-08-18T19:15:00"));
|
|
assert_eq!(
|
|
format_when(Some(&got), "european", false),
|
|
"18/08/2026 7:15pm"
|
|
);
|
|
let next_week = extract_when_from_configured(
|
|
"Review next week",
|
|
base(),
|
|
"ymd",
|
|
"sunday",
|
|
"09:00",
|
|
"09:00",
|
|
"13:00",
|
|
"18:00",
|
|
)
|
|
.unwrap();
|
|
assert!(next_week.contains("2026-08-23T09:00:00"));
|
|
}
|
|
}
|