From c8ad487ca11bf30a41e7acbe4bd98665f5be9aeb Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 19 Aug 2026 22:09:40 +0200 Subject: [PATCH] modularize date, database, and model code - Add reusable date parsing, formatting, and recurrence modules - Split database import helpers and model types into focused modules - Expose the application as a library for the binary --- src/date.rs | 8 + src/date/formatting.rs | 36 ++++ src/date/natural_language.rs | 232 +++++++++++++++++++++++++ src/date/recurrence.rs | 81 +++++++++ src/db.rs | 80 +-------- src/db/import.rs | 78 +++++++++ src/lib.rs | 11 ++ src/main.rs | 12 +- src/model.rs | 268 ++--------------------------- src/model/category.rs | 21 +++ src/model/enums.rs | 101 +++++++++++ src/model/item.rs | 39 +++++ src/model/macro_def.rs | 7 + src/model/settings.rs | 36 ++++ src/model/view.rs | 60 +++++++ src/parser.rs | 324 ----------------------------------- src/ui.rs | 2 +- 17 files changed, 731 insertions(+), 665 deletions(-) create mode 100644 src/date.rs create mode 100644 src/date/formatting.rs create mode 100644 src/date/natural_language.rs create mode 100644 src/date/recurrence.rs create mode 100644 src/db/import.rs create mode 100644 src/lib.rs create mode 100644 src/model/category.rs create mode 100644 src/model/enums.rs create mode 100644 src/model/item.rs create mode 100644 src/model/macro_def.rs create mode 100644 src/model/settings.rs create mode 100644 src/model/view.rs delete mode 100644 src/parser.rs diff --git a/src/date.rs b/src/date.rs new file mode 100644 index 0000000..cdc5411 --- /dev/null +++ b/src/date.rs @@ -0,0 +1,8 @@ +mod formatting; +mod natural_language; +mod recurrence; + +pub use formatting::format_when; +pub use natural_language::{DateParseConfig, extract_when_configured}; +pub(crate) use recurrence::RECURRENCE_INTERVAL; +pub use recurrence::next_occurrence; diff --git a/src/date/formatting.rs b/src/date/formatting.rs new file mode 100644 index 0000000..a24a703 --- /dev/null +++ b/src/date/formatting.rs @@ -0,0 +1,36 @@ +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(|date_time| { + let date = match date_format { + "us" => date_time.format("%m/%d/%Y").to_string(), + "european" => date_time.format("%d/%m/%Y").to_string(), + "long" => date_time.format("%b %-d, %Y").to_string(), + _ => date_time.format("%Y-%m-%d").to_string(), + }; + let time = if clock_24h { + date_time.format("%H:%M").to_string() + } else { + date_time.format("%-I:%M%P").to_string() + }; + format!("{date} {time}") + }) + .unwrap_or_else(|_| value.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_configured_date_and_clock_styles() { + let value = "2026-08-18T19:15:00+02:00"; + assert_eq!( + format_when(Some(value), "european", false), + "18/08/2026 7:15pm" + ); + assert_eq!(format_when(Some(value), "iso", true), "2026-08-18 19:15"); + } +} diff --git a/src/date/natural_language.rs b/src/date/natural_language.rs new file mode 100644 index 0000000..4a9cfa4 --- /dev/null +++ b/src/date/natural_language.rs @@ -0,0 +1,232 @@ +use std::sync::LazyLock; + +use chrono::{Datelike, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday}; +use regex::Regex; + +use crate::model::{DateOrder, WeekStart}; + +static ISO_DATE: LazyLock = + LazyLock::new(|| Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").expect("valid ISO date regex")); +static NUMERIC_DATE: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").expect("valid numeric date regex") +}); +static IN_DAYS: LazyLock = + LazyLock::new(|| Regex::new(r"\bin\s+(\d+)\s+days?\b").expect("valid relative date regex")); +static TIME_12H: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").expect("valid 12-hour time regex") +}); +static TIME_24H: LazyLock = LazyLock::new(|| { + Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").expect("valid 24-hour time regex") +}); + +#[derive(Debug, Clone, Copy)] +pub struct DateParseConfig<'a> { + pub date_order: DateOrder, + pub week_start: WeekStart, + pub default_time: &'a str, + pub morning_time: &'a str, + pub afternoon_time: &'a str, + pub evening_time: &'a str, +} + +impl Default for DateParseConfig<'static> { + fn default() -> Self { + Self { + date_order: DateOrder::YearMonthDay, + week_start: WeekStart::Monday, + default_time: "09:00", + morning_time: "09:00", + afternoon_time: "13:00", + evening_time: "18:00", + } + } +} + +pub fn extract_when_configured(text: &str, config: DateParseConfig<'_>) -> Option { + extract_when_from(text, Local::now().naive_local(), config) +} + +fn extract_when_from( + text: &str, + now: NaiveDateTime, + config: DateParseConfig<'_>, +) -> Option { + let lower = text.to_lowercase(); + let mut date = None; + + if let Some(captures) = ISO_DATE.captures(&lower) { + date = NaiveDate::from_ymd_opt( + captures[1].parse().ok()?, + captures[2].parse().ok()?, + captures[3].parse().ok()?, + ); + } + + if date.is_none() + && let Some(captures) = NUMERIC_DATE.captures(&lower) + { + let values = [ + captures[1].parse::().ok()?, + captures[2].parse::().ok()?, + captures[3].parse::().ok()?, + ]; + let (year, month, day) = match config.date_order { + DateOrder::MonthDayYear => (values[2], values[0], values[1]), + DateOrder::DayMonthYear => (values[2], values[1], values[0]), + DateOrder::YearMonthDay => (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() { + if let Some(captures) = IN_DAYS.captures(&lower) { + date = Some(now.date() + Duration::days(captures[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 config.week_start == WeekStart::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; + if delta == 0 { + delta += 7; + } + date = Some(now.date() + Duration::days(delta)); + break; + } + } + } + + let date = date?; + let time = if let Some(captures) = TIME_12H.captures(&lower) { + let mut hour: u32 = captures[1].parse().ok()?; + let minute: u32 = captures + .get(2) + .map_or("0", |value| value.as_str()) + .parse() + .ok()?; + if hour == 12 { + hour = 0; + } + if &captures[3] == "pm" { + hour += 12; + } + NaiveTime::from_hms_opt(hour, minute, 0)? + } else if let Some(captures) = TIME_24H.captures(&lower) { + NaiveTime::from_hms_opt(captures[1].parse().ok()?, captures[2].parse().ok()?, 0)? + } else if lower.contains("morning") { + parse_clock(config.morning_time)? + } else if lower.contains("afternoon") { + parse_clock(config.afternoon_time)? + } else if lower.contains("evening") || lower.contains("tonight") { + parse_clock(config.evening_time)? + } else { + parse_clock(config.default_time)? + }; + + Local + .from_local_datetime(&date.and_time(time)) + .earliest() + .map(|date_time| date_time.to_rfc3339()) +} + +fn parse_clock(value: &str) -> Option { + NaiveTime::parse_from_str(value, "%H:%M").ok() +} + +#[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_weekdays() { + let tomorrow = extract_when_from( + "Call Ada tomorrow at 3:30pm", + base(), + DateParseConfig::default(), + ) + .unwrap(); + assert!(tomorrow.contains("2026-08-17T15:30:00")); + + let friday = + extract_when_from("Review next Friday", base(), DateParseConfig::default()).unwrap(); + assert!(friday.contains("2026-08-21T09:00:00")); + } + + #[test] + fn ignores_undated_text() { + assert_eq!( + extract_when_from("An evergreen idea", base(), DateParseConfig::default()), + None + ); + } + + #[test] + fn honors_document_date_and_named_time_settings() { + let value = extract_when_from( + "Planning on 18/08/2026 in the evening", + base(), + DateParseConfig { + date_order: DateOrder::DayMonthYear, + week_start: WeekStart::Monday, + default_time: "08:30", + morning_time: "08:00", + afternoon_time: "14:00", + evening_time: "19:15", + }, + ) + .unwrap(); + assert!(value.contains("2026-08-18T19:15:00")); + + let next_week = extract_when_from( + "Review next week", + base(), + DateParseConfig { + week_start: WeekStart::Sunday, + ..DateParseConfig::default() + }, + ) + .unwrap(); + assert!(next_week.contains("2026-08-23T09:00:00")); + } +} diff --git a/src/date/recurrence.rs b/src/date/recurrence.rs new file mode 100644 index 0000000..7f49b69 --- /dev/null +++ b/src/date/recurrence.rs @@ -0,0 +1,81 @@ +use std::sync::LazyLock; + +use anyhow::{Context, Result, bail}; +use chrono::{Datelike, Duration, Local, Months, TimeZone, Weekday}; +use regex::Regex; + +pub(crate) static RECURRENCE_INTERVAL: LazyLock = LazyLock::new(|| { + Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").expect("valid recurrence regex") +}); + +pub fn next_occurrence(current: &str, recurrence: &str) -> Result { + let current = chrono::DateTime::parse_from_rfc3339(current)?.naive_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)) + .context("monthly date is out of range")?, + "yearly" | "annually" => current + .checked_add_months(Months::new(12)) + .context("yearly date is out of range")?, + _ => interval_occurrence(current, &rule)?, + }; + let next = Local + .from_local_datetime(&next) + .earliest() + .context("next occurrence does not exist in the local timezone")?; + Ok(next.to_rfc3339()) +} + +fn interval_occurrence( + current: chrono::NaiveDateTime, + rule: &str, +) -> Result { + let Some(captures) = RECURRENCE_INTERVAL.captures(rule) else { + bail!( + "recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'" + ); + }; + let interval: u32 = captures[1].parse()?; + if interval == 0 { + bail!("recurrence interval must be greater than zero"); + } + match &captures[2] { + "day" | "days" => Ok(current + Duration::days(i64::from(interval))), + "week" | "weeks" => Ok(current + Duration::weeks(i64::from(interval))), + "month" | "months" => current + .checked_add_months(Months::new(interval)) + .context("monthly date is out of range"), + _ => current + .checked_add_months(Months::new(interval.saturating_mul(12))) + .context("yearly date is out of range"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advances_recurring_dates_using_wall_time() { + let weekday = next_occurrence("2026-08-14T09:00:00+00:00", "weekdays").unwrap(); + assert!(weekday.contains("2026-08-17T09:00:00")); + + let fortnight = next_occurrence("2026-08-16T09:00:00+00:00", "every 2 weeks").unwrap(); + assert!(fortnight.contains("2026-08-30T09:00:00")); + } + + #[test] + fn rejects_zero_intervals() { + assert!(next_occurrence("2026-08-14T09:00:00+00:00", "every 0 days").is_err()); + } +} diff --git a/src/db.rs b/src/db.rs index 78c9d10..61427c3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,19 +9,20 @@ use anyhow::{Context, Result, bail}; use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; use rusqlite::{Connection, OptionalExtension, params}; +use crate::date::{DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence}; use crate::filter; use crate::model::{ Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection, }; -use crate::parser::{ - DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence, -}; mod presets; +mod import; pub use presets::Preset; +use self::import::{IcalRecord, ical_unescape, parse_ical_date, rrule_to_recurrence}; + fn enum_column(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result where T: FromStr, @@ -1476,37 +1477,6 @@ impl Database { } } -#[derive(Default)] -struct IcalRecord { - summary: String, - note: String, - when_at: Option, - done: bool, - completed_at: Option, - priority: Option, - categories: Vec, - recurrence: String, -} - -fn parse_ical_date(value: &str) -> Option { - if let Ok(dt) = DateTime::parse_from_rfc3339(value) { - return Some(dt.to_rfc3339()); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") { - return Some(DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339()); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { - return Local - .from_local_datetime(&dt) - .single() - .map(|d| d.to_rfc3339()); - } - NaiveDate::parse_from_str(value, "%Y%m%d") - .ok() - .and_then(|d| d.and_hms_opt(9, 0, 0)) - .and_then(|dt| Local.from_local_datetime(&dt).single()) - .map(|d| d.to_rfc3339()) -} fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> Option { let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?.naive_local(); let old = DateTime::parse_from_rfc3339(old_when?).ok()?.naive_local(); @@ -1516,14 +1486,6 @@ fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> O .earliest() .map(|date_time| date_time.to_rfc3339()) } -fn ical_unescape(value: &str) -> String { - value - .replace("\\n", "\n") - .replace("\\N", "\n") - .replace("\\,", ",") - .replace("\\;", ";") - .replace("\\\\", "\\") -} fn ical_escape(value: &str) -> String { value .replace('\\', "\\\\") @@ -1557,40 +1519,6 @@ fn recurrence_to_rrule(value: &str) -> Option { } }) } -fn rrule_to_recurrence(value: &str) -> String { - let upper = value.to_uppercase(); - let freq = upper - .split(';') - .find_map(|p| p.strip_prefix("FREQ=")) - .unwrap_or(""); - let interval = upper - .split(';') - .find_map(|p| p.strip_prefix("INTERVAL=")) - .and_then(|v| v.parse::().ok()) - .unwrap_or(1); - if upper.contains("BYDAY=MO,TU,WE,TH,FR") { - return "weekdays".into(); - } - if interval == 1 { - return match freq { - "DAILY" => "daily", - "WEEKLY" => "weekly", - "MONTHLY" => "monthly", - "YEARLY" => "yearly", - _ => "", - } - .into(); - } - let unit = match freq { - "DAILY" => "days", - "WEEKLY" => "weeks", - "MONTHLY" => "months", - "YEARLY" => "years", - _ => return String::new(), - }; - format!("every {interval} {unit}") -} - fn render_ical(items: &[Item]) -> String { let mut out = String::from( "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n", diff --git a/src/db/import.rs b/src/db/import.rs new file mode 100644 index 0000000..5fd2fa9 --- /dev/null +++ b/src/db/import.rs @@ -0,0 +1,78 @@ +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeZone, Utc}; + +#[derive(Default)] +pub(super) struct IcalRecord { + pub summary: String, + pub note: String, + pub when_at: Option, + pub done: bool, + pub completed_at: Option, + pub priority: Option, + pub categories: Vec, + pub recurrence: String, +} + +pub(super) fn parse_ical_date(value: &str) -> Option { + if let Ok(date_time) = DateTime::parse_from_rfc3339(value) { + return Some(date_time.to_rfc3339()); + } + if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") { + return Some( + DateTime::::from_naive_utc_and_offset(date_time, Utc).to_rfc3339(), + ); + } + if let Ok(date_time) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { + return Local + .from_local_datetime(&date_time) + .single() + .map(|value| value.to_rfc3339()); + } + NaiveDate::parse_from_str(value, "%Y%m%d") + .ok() + .and_then(|date| date.and_hms_opt(9, 0, 0)) + .and_then(|date_time| Local.from_local_datetime(&date_time).single()) + .map(|date_time| date_time.to_rfc3339()) +} + +pub(super) fn ical_unescape(value: &str) -> String { + value + .replace("\\n", "\n") + .replace("\\N", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") +} + +pub(super) fn rrule_to_recurrence(value: &str) -> String { + let upper = value.to_uppercase(); + let frequency = upper + .split(';') + .find_map(|part| part.strip_prefix("FREQ=")) + .unwrap_or(""); + let interval = upper + .split(';') + .find_map(|part| part.strip_prefix("INTERVAL=")) + .and_then(|value| value.parse::().ok()) + .unwrap_or(1); + if upper.contains("BYDAY=MO,TU,WE,TH,FR") { + return "weekdays".into(); + } + if interval == 1 { + return match frequency { + "DAILY" => "daily", + "WEEKLY" => "weekly", + "MONTHLY" => "monthly", + "YEARLY" => "yearly", + _ => "", + } + .into(); + } + let unit = match frequency { + "DAILY" => "days", + "WEEKLY" => "weeks", + "MONTHLY" => "months", + "YEARLY" => "years", + _ => return String::new(), + }; + format!("every {interval} {unit}") +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cc88642 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod app; +pub mod date; +pub mod db; +pub mod filter; +pub mod macro_lang; +pub mod model; +pub mod preferences; +pub mod ui; + +pub use app::App; +pub use db::{Database, Preset}; diff --git a/src/main.rs b/src/main.rs index e2ad92c..58c530d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,6 @@ -mod app; -mod db; -mod filter; -mod macro_lang; -mod model; -mod parser; -mod preferences; -mod ui; - use std::{io::stdout, path::PathBuf, time::Duration}; use anyhow::{Context, Result}; -use app::App; use clap::Parser; use crossterm::{ event::{self, Event, KeyEventKind}, @@ -18,8 +8,8 @@ use crossterm::{ execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use db::{Database, Preset}; use ratatui::{Terminal, backend::CrosstermBackend}; +use rogue_agenda::{App, Database, Preset, ui}; #[derive(Debug, Parser)] #[command( diff --git a/src/model.rs b/src/model.rs index 916b60b..94ba028 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,254 +1,16 @@ -use std::{fmt, str::FromStr}; +mod category; +mod enums; +mod item; +mod macro_def; +mod settings; +mod view; -#[derive(Debug, Clone, PartialEq)] -pub struct Item { - pub id: i64, - pub text: String, - pub note: String, - pub priority: i64, - pub when_at: Option, - pub done_at: Option, - pub alarm_at: Option, - pub numeric_value: Option, - pub recurrence: String, - pub created_at: String, - pub updated_at: String, - pub discarded: bool, - pub categories: Vec, -} - -impl Item { - pub fn category_names(&self) -> String { - self.categories - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(", ") - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Category { - pub id: i64, - pub name: String, - pub parent_id: Option, - pub kind: CategoryKind, - pub match_text: String, - pub exclusive: bool, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ViewDef { - pub id: i64, - pub name: String, - pub kind: ViewKind, - pub filter_value: String, - pub sort_key: SortKey, - pub show_done: bool, - pub filter_expr: String, - pub columns: Vec, - pub sections: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ViewColumn { - pub field: String, - pub heading: String, - pub width: u16, - pub aggregate: Aggregate, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ViewSection { - pub id: i64, - pub heading: String, - pub filter_expr: String, - pub collapsed: bool, -} - -impl ViewDef { - pub fn columns_spec(&self) -> String { - self.columns - .iter() - .map(|c| format!("{}:{}:{}:{}", c.field, c.width, c.heading, c.aggregate)) - .collect::>() - .join(",") - } - pub fn sections_spec(&self) -> String { - self.sections - .iter() - .map(|s| { - format!( - "{}|{}{}", - s.heading, - s.filter_expr, - if s.collapsed { "|collapsed" } else { "" } - ) - }) - .collect::>() - .join(";") - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CategoryRule { - pub id: i64, - pub category_id: i64, - pub condition_expr: String, - pub action_kind: RuleActionKind, - pub action_value: String, - pub enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MacroDef { - pub id: i64, - pub name: String, - pub source: String, - pub key_binding: String, -} - -#[derive(Debug, Clone, Default)] -pub struct ItemChanges { - pub text: Option, - pub note: Option, - pub priority: Option, - pub when_at: Option>, - pub alarm_at: Option>, - pub numeric_value: Option>, - pub recurrence: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DocumentSettings { - pub description: String, - pub backup_on_open: bool, - pub trash_policy: TrashPolicy, - pub done_policy: DonePolicy, - pub automatic_filing: bool, - pub date_order: DateOrder, - pub week_start: WeekStart, - pub default_time: String, - pub morning_time: String, - pub afternoon_time: String, - pub evening_time: String, - pub note_tab_width: u8, -} - -impl Default for DocumentSettings { - fn default() -> Self { - Self { - description: String::new(), - backup_on_open: false, - trash_policy: TrashPolicy::OnDemand, - done_policy: DonePolicy::Keep, - automatic_filing: true, - date_order: DateOrder::YearMonthDay, - week_start: WeekStart::Monday, - default_time: "09:00".into(), - morning_time: "09:00".into(), - afternoon_time: "13:00".into(), - evening_time: "18:00".into(), - note_tab_width: 4, - } - } -} -macro_rules! string_enum { - ($name:ident { $($variant:ident => $value:literal),+ $(,)? }) => { - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum $name { - $($variant),+ - } - - impl $name { - pub const fn as_str(self) -> &'static str { - match self { - $(Self::$variant => $value),+ - } - } - } - - impl fmt::Display for $name { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } - } - - impl FromStr for $name { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - $($value => Ok(Self::$variant),)+ - _ => Err(format!("unsupported {} {value:?}", stringify!($name))), - } - } - } - }; -} - -string_enum!(CategoryKind { - Standard => "standard", - Date => "date", - Numeric => "numeric", -}); - -string_enum!(ViewKind { - List => "list", - Category => "category", - Upcoming => "upcoming", - Done => "done", - Datebook => "datebook", - Trash => "trash", -}); - -string_enum!(SortKey { - Manual => "manual", - When => "when", - Done => "done", - Priority => "priority", - Updated => "updated", -}); - -string_enum!(Aggregate { - None => "none", - Sum => "sum", - Average => "avg", - Count => "count", - Minimum => "min", - Maximum => "max", -}); - -string_enum!(RuleActionKind { - Assign => "assign", - Exclude => "exclude", - Priority => "priority", - Value => "value", - When => "when", - Alarm => "alarm", - Repeat => "repeat", - Done => "done", -}); - -string_enum!(TrashPolicy { - OnDemand => "on-demand", - OnClose => "on-close", - EndOfDay => "end-of-day", - Immediate => "immediate", -}); - -string_enum!(DonePolicy { - Keep => "keep", - Trash => "trash", -}); - -string_enum!(DateOrder { - YearMonthDay => "ymd", - MonthDayYear => "mdy", - DayMonthYear => "dmy", -}); - -string_enum!(WeekStart { - Monday => "monday", - Sunday => "sunday", -}); +pub use category::{Category, CategoryRule}; +pub use enums::{ + Aggregate, CategoryKind, DateOrder, DonePolicy, RuleActionKind, SortKey, TrashPolicy, ViewKind, + WeekStart, +}; +pub use item::{Item, ItemChanges}; +pub use macro_def::MacroDef; +pub use settings::DocumentSettings; +pub use view::{ViewColumn, ViewDef, ViewSection}; diff --git a/src/model/category.rs b/src/model/category.rs new file mode 100644 index 0000000..ac8c099 --- /dev/null +++ b/src/model/category.rs @@ -0,0 +1,21 @@ +use super::{CategoryKind, RuleActionKind}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Category { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub kind: CategoryKind, + pub match_text: String, + pub exclusive: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CategoryRule { + pub id: i64, + pub category_id: i64, + pub condition_expr: String, + pub action_kind: RuleActionKind, + pub action_value: String, + pub enabled: bool, +} diff --git a/src/model/enums.rs b/src/model/enums.rs new file mode 100644 index 0000000..96ece9d --- /dev/null +++ b/src/model/enums.rs @@ -0,0 +1,101 @@ +use std::{fmt, str::FromStr}; + +macro_rules! string_enum { + ($name:ident { $($variant:ident => $value:literal),+ $(,)? }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum $name { + $($variant),+ + } + + impl $name { + pub const fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $value),+ + } + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } + } + + impl FromStr for $name { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + $($value => Ok(Self::$variant),)+ + _ => Err(format!("unsupported {} {value:?}", stringify!($name))), + } + } + } + }; +} + +string_enum!(CategoryKind { + Standard => "standard", + Date => "date", + Numeric => "numeric", +}); + +string_enum!(ViewKind { + List => "list", + Category => "category", + Upcoming => "upcoming", + Done => "done", + Datebook => "datebook", + Trash => "trash", +}); + +string_enum!(SortKey { + Manual => "manual", + When => "when", + Done => "done", + Priority => "priority", + Updated => "updated", +}); + +string_enum!(Aggregate { + None => "none", + Sum => "sum", + Average => "avg", + Count => "count", + Minimum => "min", + Maximum => "max", +}); + +string_enum!(RuleActionKind { + Assign => "assign", + Exclude => "exclude", + Priority => "priority", + Value => "value", + When => "when", + Alarm => "alarm", + Repeat => "repeat", + Done => "done", +}); + +string_enum!(TrashPolicy { + OnDemand => "on-demand", + OnClose => "on-close", + EndOfDay => "end-of-day", + Immediate => "immediate", +}); + +string_enum!(DonePolicy { + Keep => "keep", + Trash => "trash", +}); + +string_enum!(DateOrder { + YearMonthDay => "ymd", + MonthDayYear => "mdy", + DayMonthYear => "dmy", +}); + +string_enum!(WeekStart { + Monday => "monday", + Sunday => "sunday", +}); diff --git a/src/model/item.rs b/src/model/item.rs new file mode 100644 index 0000000..07aacaa --- /dev/null +++ b/src/model/item.rs @@ -0,0 +1,39 @@ +use super::Category; + +#[derive(Debug, Clone, PartialEq)] +pub struct Item { + pub id: i64, + pub text: String, + pub note: String, + pub priority: i64, + pub when_at: Option, + pub done_at: Option, + pub alarm_at: Option, + pub numeric_value: Option, + pub recurrence: String, + pub created_at: String, + pub updated_at: String, + pub discarded: bool, + pub categories: Vec, +} + +impl Item { + pub fn category_names(&self) -> String { + self.categories + .iter() + .map(|category| category.name.as_str()) + .collect::>() + .join(", ") + } +} + +#[derive(Debug, Clone, Default)] +pub struct ItemChanges { + pub text: Option, + pub note: Option, + pub priority: Option, + pub when_at: Option>, + pub alarm_at: Option>, + pub numeric_value: Option>, + pub recurrence: Option, +} diff --git a/src/model/macro_def.rs b/src/model/macro_def.rs new file mode 100644 index 0000000..4973ca3 --- /dev/null +++ b/src/model/macro_def.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacroDef { + pub id: i64, + pub name: String, + pub source: String, + pub key_binding: String, +} diff --git a/src/model/settings.rs b/src/model/settings.rs new file mode 100644 index 0000000..a6c488f --- /dev/null +++ b/src/model/settings.rs @@ -0,0 +1,36 @@ +use super::{DateOrder, DonePolicy, TrashPolicy, WeekStart}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocumentSettings { + pub description: String, + pub backup_on_open: bool, + pub trash_policy: TrashPolicy, + pub done_policy: DonePolicy, + pub automatic_filing: bool, + pub date_order: DateOrder, + pub week_start: WeekStart, + pub default_time: String, + pub morning_time: String, + pub afternoon_time: String, + pub evening_time: String, + pub note_tab_width: u8, +} + +impl Default for DocumentSettings { + fn default() -> Self { + Self { + description: String::new(), + backup_on_open: false, + trash_policy: TrashPolicy::OnDemand, + done_policy: DonePolicy::Keep, + automatic_filing: true, + date_order: DateOrder::YearMonthDay, + week_start: WeekStart::Monday, + default_time: "09:00".into(), + morning_time: "09:00".into(), + afternoon_time: "13:00".into(), + evening_time: "18:00".into(), + note_tab_width: 4, + } + } +} diff --git a/src/model/view.rs b/src/model/view.rs new file mode 100644 index 0000000..4038d0b --- /dev/null +++ b/src/model/view.rs @@ -0,0 +1,60 @@ +use super::{Aggregate, SortKey, ViewKind}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewDef { + pub id: i64, + pub name: String, + pub kind: ViewKind, + pub filter_value: String, + pub sort_key: SortKey, + pub show_done: bool, + pub filter_expr: String, + pub columns: Vec, + pub sections: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewColumn { + pub field: String, + pub heading: String, + pub width: u16, + pub aggregate: Aggregate, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewSection { + pub id: i64, + pub heading: String, + pub filter_expr: String, + pub collapsed: bool, +} + +impl ViewDef { + pub fn columns_spec(&self) -> String { + self.columns + .iter() + .map(|column| { + format!( + "{}:{}:{}:{}", + column.field, column.width, column.heading, column.aggregate + ) + }) + .collect::>() + .join(",") + } + + pub fn sections_spec(&self) -> String { + self.sections + .iter() + .map(|section| { + format!( + "{}|{}{}", + section.heading, + section.filter_expr, + if section.collapsed { "|collapsed" } else { "" } + ) + }) + .collect::>() + .join(";") + } +} diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index 536b50c..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,324 +0,0 @@ -use std::sync::LazyLock; - -use anyhow::{Context, Result, bail}; -use chrono::{ - Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday, -}; -use regex::Regex; - -use crate::model::{DateOrder, WeekStart}; - -static ISO_DATE: LazyLock = - LazyLock::new(|| Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").expect("valid ISO date regex")); -static NUMERIC_DATE: LazyLock = LazyLock::new(|| { - Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").expect("valid numeric date regex") -}); -static IN_DAYS: LazyLock = - LazyLock::new(|| Regex::new(r"\bin\s+(\d+)\s+days?\b").expect("valid relative date regex")); -static TIME_12H: LazyLock = LazyLock::new(|| { - Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").expect("valid 12-hour time regex") -}); -static TIME_24H: LazyLock = LazyLock::new(|| { - Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").expect("valid 24-hour time regex") -}); -pub(crate) static RECURRENCE_INTERVAL: LazyLock = LazyLock::new(|| { - Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").expect("valid recurrence regex") -}); - -#[derive(Debug, Clone, Copy)] -pub struct DateParseConfig<'a> { - pub date_order: DateOrder, - pub week_start: WeekStart, - pub default_time: &'a str, - pub morning_time: &'a str, - pub afternoon_time: &'a str, - pub evening_time: &'a str, -} - -#[cfg(test)] -pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { - extract_when_from_configured(text, now, DateParseConfig::default()) -} - -impl Default for DateParseConfig<'static> { - fn default() -> Self { - Self { - date_order: DateOrder::YearMonthDay, - week_start: WeekStart::Monday, - default_time: "09:00", - morning_time: "09:00", - afternoon_time: "13:00", - evening_time: "18:00", - } - } -} - -pub fn extract_when_configured(text: &str, config: DateParseConfig<'_>) -> Option { - extract_when_from_configured(text, Local::now().naive_local(), config) -} - -fn extract_when_from_configured( - text: &str, - now: NaiveDateTime, - config: DateParseConfig<'_>, -) -> Option { - let lower = text.to_lowercase(); - let mut date = None; - - if let Some(c) = ISO_DATE.captures(&lower) { - date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?); - } - - if date.is_none() - && let Some(c) = NUMERIC_DATE.captures(&lower) - { - let values = [ - c[1].parse::().ok()?, - c[2].parse::().ok()?, - c[3].parse::().ok()?, - ]; - let (year, month, day) = match config.date_order { - DateOrder::MonthDayYear => (values[2], values[0], values[1]), - DateOrder::DayMonthYear => (values[2], values[1], values[0]), - DateOrder::YearMonthDay => (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() { - 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 config.week_start == WeekStart::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 = if let Some(c) = TIME_12H.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) = TIME_24H.captures(&lower) { - NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)? - } else if lower.contains("morning") { - parse_clock(config.morning_time)? - } else if lower.contains("afternoon") { - parse_clock(config.afternoon_time)? - } else if lower.contains("evening") || lower.contains("tonight") { - parse_clock(config.evening_time)? - } else { - parse_clock(config.default_time)? - }; - - let local = Local.from_local_datetime(&date.and_time(time)).earliest()?; - Some(local.to_rfc3339()) -} - -fn parse_clock(value: &str) -> Option { - 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 { - // Recurrences are calendar events: the date and clock reading written in the - // document are authoritative, while the stored UTC offset is informational. - let current = chrono::DateTime::parse_from_rfc3339(current)?.naive_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 Some(c) = RECURRENCE_INTERVAL.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()?; - if n == 0 { - bail!("recurrence interval must be greater than zero"); - } - 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"))?, - } - } - }; - let next = Local - .from_local_datetime(&next) - .earliest() - .context("next occurrence does not exist in the local timezone")?; - 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 config = DateParseConfig { - date_order: DateOrder::DayMonthYear, - week_start: WeekStart::Monday, - default_time: "08:30", - morning_time: "08:00", - afternoon_time: "14:00", - evening_time: "19:15", - }; - let got = - extract_when_from_configured("Planning on 18/08/2026 in the evening", base(), config) - .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(), - DateParseConfig { - week_start: WeekStart::Sunday, - ..DateParseConfig::default() - }, - ) - .unwrap(); - assert!(next_week.contains("2026-08-23T09:00:00")); - } - - #[test] - fn recurrence_uses_the_written_wall_time_and_rejects_zero_intervals() { - let next = next_occurrence("2026-08-14T09:00:00+00:00", "daily").unwrap(); - assert!(next.contains("2026-08-15T09:00:00")); - assert!(next_occurrence("2026-08-14T09:00:00+00:00", "every 0 days").is_err()); - } -} diff --git a/src/ui.rs b/src/ui.rs index 9c76c83..3198cf9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,9 +11,9 @@ use ratatui::{ use crate::{ app::{App, FormKind, InputKind, Mode, form_choices}, + date::format_when, filter, model::{Aggregate, Item, ViewColumn, ViewKind}, - parser::format_when, }; #[derive(Clone, Copy)]