From 2d4e4ae4d7ef3a08fee17a628c5710b49eab89bf Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 2 Jun 2026 20:02:22 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20parse=20"every=20Nth"=20recurrence?= =?UTF-8?q?=20=E2=86=92=20monthly=20by=20day-of-month?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Todoist uses "every 5th" for monthly-on-the-5th; map it to FREQ=MONTHLY;BYMONTHDAY=N (1..=31). Surfaced by the Todoist import. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph/src/datespec.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/heph/src/datespec.rs b/crates/heph/src/datespec.rs index cb01493..d37ed21 100644 --- a/crates/heph/src/datespec.rs +++ b/crates/heph/src/datespec.rs @@ -177,6 +177,10 @@ pub fn parse_recurrence(spec: &str) -> Result { if let Some((m, d)) = parse_month_day(body) { return Ok(format!("FREQ=YEARLY;BYMONTH={m};BYMONTHDAY={d}")); } + // "every " → monthly on that day of the month ("every 5th"). + if let Some(d) = parse_monthday_ordinal(body) { + return Ok(format!("FREQ=MONTHLY;BYMONTHDAY={d}")); + } // "every " / "every N s". let mut it = body.split_whitespace(); let first = it.next().unwrap_or(""); @@ -224,6 +228,19 @@ fn byday(wd: Weekday) -> &'static str { } } +/// Parse a day-of-month ordinal like "5th", "1st", "22nd", "3rd" → 1..=31. +fn parse_monthday_ordinal(s: &str) -> Option { + let digits = s.trim_end_matches(|c: char| c.is_ascii_alphabetic()); + let suffix = &s[digits.len()..]; + if !matches!(suffix, "st" | "nd" | "rd" | "th") { + return None; + } + match digits.parse::() { + Ok(d @ 1..=31) => Some(d), + _ => None, + } +} + /// Parse "april 15" / "apr 15" (and the reversed "15 april") → (month, day). fn parse_month_day(s: &str) -> Option<(u32, u32)> { let toks: Vec<&str> = s.split_whitespace().collect(); @@ -367,6 +384,14 @@ mod tests { parse_recurrence("every April 15").unwrap(), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15" ); + assert_eq!( + parse_recurrence("every 5th").unwrap(), + "FREQ=MONTHLY;BYMONTHDAY=5" + ); + assert_eq!( + parse_recurrence("every 22nd").unwrap(), + "FREQ=MONTHLY;BYMONTHDAY=22" + ); assert!(parse_recurrence("every blue moon").is_err()); } }