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
This commit is contained in:
Chili Palmer
2026-08-19 22:09:40 +02:00
parent a6b38af396
commit c8ad487ca1
17 changed files with 731 additions and 665 deletions

8
src/date.rs Normal file
View File

@@ -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;

36
src/date/formatting.rs Normal file
View File

@@ -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");
}
}

View File

@@ -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<Regex> =
LazyLock::new(|| Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").expect("valid ISO date regex"));
static NUMERIC_DATE: LazyLock<Regex> = 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<Regex> =
LazyLock::new(|| Regex::new(r"\bin\s+(\d+)\s+days?\b").expect("valid relative date regex"));
static TIME_12H: LazyLock<Regex> = 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<Regex> = 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<String> {
extract_when_from(text, Local::now().naive_local(), config)
}
fn extract_when_from(
text: &str,
now: NaiveDateTime,
config: DateParseConfig<'_>,
) -> Option<String> {
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::<i32>().ok()?,
captures[2].parse::<i32>().ok()?,
captures[3].parse::<i32>().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> {
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"));
}
}

81
src/date/recurrence.rs Normal file
View File

@@ -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<Regex> = 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<String> {
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<chrono::NaiveDateTime> {
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());
}
}

View File

@@ -9,19 +9,20 @@ use anyhow::{Context, Result, bail};
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use crate::date::{DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence};
use crate::filter; use crate::filter;
use crate::model::{ use crate::model::{
Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef, Aggregate, Category, CategoryRule, DocumentSettings, DonePolicy, Item, ItemChanges, MacroDef,
RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection, RuleActionKind, SortKey, TrashPolicy, ViewColumn, ViewDef, ViewKind, ViewSection,
}; };
use crate::parser::{
DateParseConfig, RECURRENCE_INTERVAL, extract_when_configured, next_occurrence,
};
mod presets; mod presets;
mod import;
pub use presets::Preset; pub use presets::Preset;
use self::import::{IcalRecord, ical_unescape, parse_ical_date, rrule_to_recurrence};
fn enum_column<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T> fn enum_column<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T>
where where
T: FromStr, T: FromStr,
@@ -1476,37 +1477,6 @@ impl Database {
} }
} }
#[derive(Default)]
struct IcalRecord {
summary: String,
note: String,
when_at: Option<String>,
done: bool,
completed_at: Option<String>,
priority: Option<i64>,
categories: Vec<String>,
recurrence: String,
}
fn parse_ical_date(value: &str) -> Option<String> {
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::<Utc>::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<String> { fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> Option<String> {
let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?.naive_local(); let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?.naive_local();
let old = DateTime::parse_from_rfc3339(old_when?).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() .earliest()
.map(|date_time| date_time.to_rfc3339()) .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 { fn ical_escape(value: &str) -> String {
value value
.replace('\\', "\\\\") .replace('\\', "\\\\")
@@ -1557,40 +1519,6 @@ fn recurrence_to_rrule(value: &str) -> Option<String> {
} }
}) })
} }
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::<u32>().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 { fn render_ical(items: &[Item]) -> String {
let mut out = String::from( let mut out = String::from(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n", "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n",

78
src/db/import.rs Normal file
View File

@@ -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<String>,
pub done: bool,
pub completed_at: Option<String>,
pub priority: Option<i64>,
pub categories: Vec<String>,
pub recurrence: String,
}
pub(super) fn parse_ical_date(value: &str) -> Option<String> {
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::<Utc>::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::<u32>().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}")
}

11
src/lib.rs Normal file
View File

@@ -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};

View File

@@ -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 std::{io::stdout, path::PathBuf, time::Duration};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use app::App;
use clap::Parser; use clap::Parser;
use crossterm::{ use crossterm::{
event::{self, Event, KeyEventKind}, event::{self, Event, KeyEventKind},
@@ -18,8 +8,8 @@ use crossterm::{
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
}; };
use db::{Database, Preset};
use ratatui::{Terminal, backend::CrosstermBackend}; use ratatui::{Terminal, backend::CrosstermBackend};
use rogue_agenda::{App, Database, Preset, ui};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(

View File

@@ -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 use category::{Category, CategoryRule};
pub struct Item { pub use enums::{
pub id: i64, Aggregate, CategoryKind, DateOrder, DonePolicy, RuleActionKind, SortKey, TrashPolicy, ViewKind,
pub text: String, WeekStart,
pub note: String, };
pub priority: i64, pub use item::{Item, ItemChanges};
pub when_at: Option<String>, pub use macro_def::MacroDef;
pub done_at: Option<String>, pub use settings::DocumentSettings;
pub alarm_at: Option<String>, pub use view::{ViewColumn, ViewDef, ViewSection};
pub numeric_value: Option<f64>,
pub recurrence: String,
pub created_at: String,
pub updated_at: String,
pub discarded: bool,
pub categories: Vec<Category>,
}
impl Item {
pub fn category_names(&self) -> String {
self.categories
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Category {
pub id: i64,
pub name: String,
pub parent_id: Option<i64>,
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<ViewColumn>,
pub sections: Vec<ViewSection>,
}
#[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::<Vec<_>>()
.join(",")
}
pub fn sections_spec(&self) -> String {
self.sections
.iter()
.map(|s| {
format!(
"{}|{}{}",
s.heading,
s.filter_expr,
if s.collapsed { "|collapsed" } else { "" }
)
})
.collect::<Vec<_>>()
.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<String>,
pub note: Option<String>,
pub priority: Option<i64>,
pub when_at: Option<Option<String>>,
pub alarm_at: Option<Option<String>>,
pub numeric_value: Option<Option<f64>>,
pub recurrence: Option<String>,
}
#[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<Self, Self::Err> {
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",
});

21
src/model/category.rs Normal file
View File

@@ -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<i64>,
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,
}

101
src/model/enums.rs Normal file
View File

@@ -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<Self, Self::Err> {
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",
});

39
src/model/item.rs Normal file
View File

@@ -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<String>,
pub done_at: Option<String>,
pub alarm_at: Option<String>,
pub numeric_value: Option<f64>,
pub recurrence: String,
pub created_at: String,
pub updated_at: String,
pub discarded: bool,
pub categories: Vec<Category>,
}
impl Item {
pub fn category_names(&self) -> String {
self.categories
.iter()
.map(|category| category.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Default)]
pub struct ItemChanges {
pub text: Option<String>,
pub note: Option<String>,
pub priority: Option<i64>,
pub when_at: Option<Option<String>>,
pub alarm_at: Option<Option<String>>,
pub numeric_value: Option<Option<f64>>,
pub recurrence: Option<String>,
}

7
src/model/macro_def.rs Normal file
View File

@@ -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,
}

36
src/model/settings.rs Normal file
View File

@@ -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,
}
}
}

60
src/model/view.rs Normal file
View File

@@ -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<ViewColumn>,
pub sections: Vec<ViewSection>,
}
#[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::<Vec<_>>()
.join(",")
}
pub fn sections_spec(&self) -> String {
self.sections
.iter()
.map(|section| {
format!(
"{}|{}{}",
section.heading,
section.filter_expr,
if section.collapsed { "|collapsed" } else { "" }
)
})
.collect::<Vec<_>>()
.join(";")
}
}

View File

@@ -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<Regex> =
LazyLock::new(|| Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").expect("valid ISO date regex"));
static NUMERIC_DATE: LazyLock<Regex> = 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<Regex> =
LazyLock::new(|| Regex::new(r"\bin\s+(\d+)\s+days?\b").expect("valid relative date regex"));
static TIME_12H: LazyLock<Regex> = 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<Regex> = 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<Regex> = 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<String> {
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<String> {
extract_when_from_configured(text, Local::now().naive_local(), config)
}
fn extract_when_from_configured(
text: &str,
now: NaiveDateTime,
config: DateParseConfig<'_>,
) -> Option<String> {
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::<i32>().ok()?,
c[2].parse::<i32>().ok()?,
c[3].parse::<i32>().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> {
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> {
// 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());
}
}

View File

@@ -11,9 +11,9 @@ use ratatui::{
use crate::{ use crate::{
app::{App, FormKind, InputKind, Mode, form_choices}, app::{App, FormKind, InputKind, Mode, form_choices},
date::format_when,
filter, filter,
model::{Aggregate, Item, ViewColumn, ViewKind}, model::{Aggregate, Item, ViewColumn, ViewKind},
parser::format_when,
}; };
#[derive(Clone, Copy)] #[derive(Clone, Copy)]