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:
8
src/date.rs
Normal file
8
src/date.rs
Normal 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
36
src/date/formatting.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
232
src/date/natural_language.rs
Normal file
232
src/date/natural_language.rs
Normal 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
81
src/date/recurrence.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
80
src/db.rs
80
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<T>(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result<T>
|
||||
where
|
||||
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> {
|
||||
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<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 {
|
||||
let mut out = String::from(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n",
|
||||
|
||||
78
src/db/import.rs
Normal file
78
src/db/import.rs
Normal 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
11
src/lib.rs
Normal 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};
|
||||
12
src/main.rs
12
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(
|
||||
|
||||
266
src/model.rs
266
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<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(|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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use category::{Category, CategoryRule};
|
||||
pub use enums::{
|
||||
Aggregate, CategoryKind, DateOrder, DonePolicy, RuleActionKind, SortKey, TrashPolicy, ViewKind,
|
||||
WeekStart,
|
||||
};
|
||||
}
|
||||
|
||||
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 item::{Item, ItemChanges};
|
||||
pub use macro_def::MacroDef;
|
||||
pub use settings::DocumentSettings;
|
||||
pub use view::{ViewColumn, ViewDef, ViewSection};
|
||||
|
||||
21
src/model/category.rs
Normal file
21
src/model/category.rs
Normal 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
101
src/model/enums.rs
Normal 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
39
src/model/item.rs
Normal 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
7
src/model/macro_def.rs
Normal 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
36
src/model/settings.rs
Normal 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
60
src/model/view.rs
Normal 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(";")
|
||||
}
|
||||
}
|
||||
324
src/parser.rs
324
src/parser.rs
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user