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 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
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 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(
|
||||||
|
|||||||
268
src/model.rs
268
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 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
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user